Merge branch 'main' into feat/bikeshare-detroit-mogo

This commit is contained in:
Sameh Khamis committed 2026-10-15 10:47:45 -07:00
commit cfcd2a636f
230 files changed
+24435 -1652

No files matched your search

+4 -2
View File
@@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 });
globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
const requestTimes = [];
globalThis.fetch = async () => {
globalThis.fetch = async (url) => {
if (String(url).endsWith('/status'))
return Response.json({ configured: true });
requestTimes.push(Date.now());
return httpFailure(500);
};
@@ -402,7 +404,7 @@ test('outline upgrade updates the rendered element in place without remove/add',
};
let overpassCall = 0;
globalThis.fetch = async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({
status: 'OK',
results: [{
+10 -10
View File
@@ -200,7 +200,7 @@ test('ask-side admin bypass: "the Texas Capitol" recovers near-view despite a fa
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -235,7 +235,7 @@ test('ask-side admin bypass: explicit "state of Texas" skips recovery and proxim
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
assert.match(String(url), /^https:\/\/maps\.googleapis\.com\/maps\/api\/geocode/);
assert.match(String(url), /^\/api\/google\/geocode\?/);
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -273,7 +273,7 @@ for (const fixture of [
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: fixture.lat,
lon: fixture.lon,
@@ -299,7 +299,7 @@ test('ask-side admin bypass: bare "Texas" remains on the guarded recovery path',
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => geocodePayload({
lat: 31.0000,
lon: -99.0000,
@@ -328,7 +328,7 @@ test('ask-side admin bypass: admin level 2/3 result types never grant a township
const calls = [];
installGoogleMocks(t, async (url) => {
calls.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
const query = new URL(String(url)).searchParams.get('address');
return { json: async () => geocodePayload({
lat: 39.7817,
@@ -380,7 +380,7 @@ function installCapitolMocks(t, elements, components = [
{ long_name: 'Capitol Hill', types: ['neighborhood', 'political'] },
]) {
installGoogleMocks(t, async (url) => {
if (String(url).startsWith('https://maps.googleapis.com/')) {
if (String(url).startsWith('/api/google/geocode')) {
return { json: async () => ({ status: 'OK', results: [{
formatted_address: 'Washington, DC 20004, USA',
types: ['establishment', 'landmark', 'point_of_interest', 'tourist_attraction'],
@@ -458,7 +458,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
return {
ok: true,
json: async () => ({
@@ -495,7 +495,7 @@ test('keyless: a key that geocodes to nothing still anchors the annotation', asy
high: { latitude: 30.28, longitude: -97.68 },
});
// Google is asked first and exactly once; one Photon call answers it.
assert.equal(requests.filter((url) => url.includes('maps.googleapis.com')).length, 1);
assert.equal(requests.filter((url) => url.includes('/api/google/geocode')).length, 1);
assert.equal(requests.filter((url) => url.includes('photon.komoot.io')).length, 1);
});
@@ -512,7 +512,7 @@ test('keyless: OSM matching uses the feature\'s canonical name, not the user\'s
installGoogleMocks(t, async (url, init) => {
const href = String(url);
if (href.startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (href.startsWith('/api/google/text-search')) return { ok: true, json: async () => ({ places: [] }) };
if (href.startsWith('https://photon.komoot.io/')) {
return {
@@ -575,7 +575,7 @@ test('keyless: a Photon outage is not remembered as "no such place"', async (t)
const requests = [];
installGoogleMocks(t, async (url) => {
requests.push(String(url));
if (String(url).startsWith('https://maps.googleapis.com/')) return GOOGLE_FOUND_NOTHING;
if (String(url).startsWith('/api/google/geocode')) return GOOGLE_FOUND_NOTHING;
if (!photonReachable) throw new Error('network down');
return photonUp;
});
+5 -2
View File
@@ -51,9 +51,11 @@ test('region ring: Natural Earth names resolve without a geocoder', async () =>
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
let adminSignal;
let release;
let lookupStarted;
const { resolveRegionRingForQuery } = createAnnotationResolver({
featureSource: featureSource({
getAdministrativeAreas(_point, { signal }) {
lookupStarted = Date.now();
adminSignal = signal;
return new Promise((resolve) => {
release = resolve;
@@ -61,7 +63,6 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
},
}),
});
const started = Date.now();
const region = await resolveRegionRingForQuery(
'Atlantis',
undefined,
@@ -73,8 +74,10 @@ test('region ring: a slow admin-boundary lookup returns region-timeout within th
ring: null,
error: 'region-timeout',
});
// Timed from the boundary lookup, so the bundled pack scans before it do not
// count. Under the 3 s default budget, so an ignored budgetMs still fails.
assert.ok(
Date.now() - started < 1000,
Date.now() - lookupStarted < 2_000,
'returns at the budget, not the lookup',
);
// The lookup is left running so it can fill the boundary cache.
+4
View File
@@ -25,6 +25,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
@@ -120,6 +121,9 @@ export function createApplicationCatalog({
createApplicationFirePerimeters({
source: sources['fire-perimeters'],
}),
createApplicationGnssIntegrity({
source: sources['gnss-integrity'],
}),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({
surface,
+3 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 30);
assert.equal(first.layers.length, 31);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual(
first.metadata.find(({ id }) => id === 'local-adsb'),
@@ -60,6 +60,7 @@ test('catalogs construct distinct layers and classification from their supplied
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('fire-perimeters'));
assert.ok(first.get('gnss-integrity'));
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -153,7 +154,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal),
};
const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 30);
assert.equal(catalog.layers.length, 31);
assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal(
catalogControlServices(catalog).streetLevelLayer,
+6
View File
@@ -0,0 +1,6 @@
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer(options);
}
+6 -1
View File
@@ -1,6 +1,7 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
@@ -10,12 +11,16 @@ import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */
/**
* Mapillary, Google Street View and KartaView, once the Mapillary source is
* supplied; else none.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
createKartaViewProvider(),
];
}
+8 -3
View File
@@ -28,7 +28,7 @@ test('explicit providers replace the default source and an empty list removes it
);
});
test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => {
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
@@ -37,7 +37,7 @@ test('the defaults are Mapillary (given its source) and Street View, which start
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, ['mapillary', 'google']);
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
@@ -45,7 +45,12 @@ test('the defaults are Mapillary (given its source) and Street View, which start
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(layer.getParams().providerSwitches, 'google-1');
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
// KartaView is keyless and free, but one source shows at a time.
layer.setParams({ providerSwitches: 'kartaview-1' });
assert.equal(layer.getParams().kartaview, true);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, false);
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
+1
View File
@@ -46,6 +46,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
cyclones: contract(['weather-cyclones'], ['getSnapshot']),
earthquakes: contract(['earthquakes'], ['getSnapshot']),
'fire-perimeters': contract(['fire-perimeters'], ['getSnapshot']),
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
});
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t);
assert.equal(actual.layers.length, 30);
assert.equal(actual.layers.length, 31);
const manager = new LayerLifecycle(
{},
{ getSourceAvailability: actual.getSourceAvailability },
+11
View File
@@ -1,6 +1,7 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initGeoFileImport } from '../ui/geoFileImport.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
@@ -56,6 +57,16 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
// own data source. They live for the page session, so the application
// lifetime removes them and the drop listeners on the viewer container.
const geoFileImport = initGeoFileImport({
viewer,
navigation: styleManager,
ground: operations.surface?.groundFloor,
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
});
defer(() => geoFileImport?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
+20
View File
@@ -83,6 +83,26 @@ export function cockpitAltitudeDisplayFt(altitudeM, onGround) {
return Number.isFinite(altitudeM) ? altitudeM * 3.28084 : null;
}
/**
* HUD wording for a tracked aircraft's transponder emergency, or null.
* States what was broadcast, never a conclusion about the flight: the Mode A
* code when it names the status, else the ADS-B field it came from (an
* unrelated squawk is dropped).
* @param {{label?: string, squawk?: string|null, source?: string,
* severity?: string}|null|undefined} emergency
* @returns {string|null}
*/
export function formatCockpitEmergency(emergency) {
const label = String(emergency?.label || '')
.trim()
.toUpperCase();
if (!label) return null;
if (emergency.source === 'squawk' && emergency.squawk)
return `SQUAWK ${emergency.squawk} · ${label}`;
const field = emergency.severity === 'priority' ? 'PRIORITY' : 'EMERGENCY';
return `ADS-B ${field} · ${label}`;
}
/** Format the cockpit Context scope without overstating installation coverage. */
export function formatCockpitContextScope(
subjectLabel,
+19
View File
@@ -13,6 +13,7 @@ import {
compassDivisions,
formatAltitudeRulerTick,
formatCockpitContextScope,
formatCockpitEmergency,
formatCompassDivision,
formatSpeedRulerTick,
normalizeHeading,
@@ -364,3 +365,21 @@ test('tracked-identity resolution falls back to layer precedence', () => {
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
+5 -2
View File
@@ -145,17 +145,20 @@ test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
// Built with path.join so the stub matches the resolver on Windows too,
// where the separators are backslashes.
const installed = path.join('/home/fixture', '.local', 'bin', 'codex');
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate.endsWith('/.local/bin/codex')) return;
if (candidate === installed) return;
throw new Error('missing');
},
}),
path.join('/home/fixture', '.local', 'bin', 'codex'),
installed,
);
assert.equal(checked.length, 1);
+37 -8
View File
@@ -43,11 +43,18 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
.toLowerCase();
const latitude = finiteNumber(aircraft?.lat);
const longitude = finiteNumber(aircraft?.lon);
if (!hex || latitude === null || longitude === null) return null;
if (
!hex ||
latitude === null ||
longitude === null ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
return null;
const seenPosition = Math.max(
0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? 0,
finiteNumber(aircraft?.seen_pos) ?? finiteNumber(aircraft?.seen) ?? NaN,
);
const seen = Math.max(0, finiteNumber(aircraft?.seen) ?? seenPosition);
const onGround = aircraft?.alt_baro === 'ground';
@@ -62,8 +69,10 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) {
hex,
String(aircraft?.flight || aircraft?.r || '').trim() || null,
null,
Math.max(0, nowSeconds - seenPosition),
Math.max(0, nowSeconds - seen),
Number.isFinite(seenPosition)
? Math.max(0, nowSeconds - seenPosition)
: null,
Number.isFinite(seen) ? Math.max(0, nowSeconds - seen) : null,
longitude,
latitude,
barometricFeet === null ? null : barometricFeet * FOOT_TO_M,
@@ -94,8 +103,28 @@ export function normalizeAdsbLolPointResponse(payload) {
: Math.floor(
responseNow > 10_000_000_000 ? responseNow / 1000 : responseNow,
);
const states = (Array.isArray(payload?.ac) ? payload.ac : [])
.map((aircraft) => normalizeAdsbLolAircraftState(aircraft, nowSeconds))
.filter(Boolean);
return { time: nowSeconds, states };
const aircraft = Array.isArray(payload?.ac) ? payload.ac : [];
const states = [];
// Keep the OpenSky vectors unchanged. The regional fallback's extra readsb
// status travels alongside them, keyed only to admitted aircraft.
const admitted = new Set();
const statuses = [];
for (const row of aircraft) {
const state = normalizeAdsbLolAircraftState(row, nowSeconds);
if (!state) continue;
states.push(state);
// The live contract admits the first valid vector for each ICAO. Its
// status must come from that same row, never an invalid or later duplicate.
const id = state[0];
if (admitted.has(id)) continue;
admitted.add(id);
if (typeof row.emergency === 'string')
statuses.push([id, row.emergency.trim().toLowerCase()]);
}
const emergencyByIcao = Object.fromEntries(statuses);
return {
time: nowSeconds,
states,
...(Object.keys(emergencyByIcao).length ? { emergencyByIcao } : {}),
};
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Transponder emergency status for one aircraft, from the Mode A squawk and
* the ADS-B emergency/priority field.
*
* Emergency squawks are ICAO-standard worldwide: 7500 unlawful interference,
* 7600 radio failure, 7700 general emergency. readsb (adsb.lol) also reports
* `emergency`, "a superset of the 7x00 squawks": none, general, lifeguard,
* minfuel, nordo, unlawful, downed, reserved. OpenSky state vectors carry the
* squawk only.
*
* The status is what the transponder broadcast in THIS poll, never a finding:
* crews pass through 7x00 codes while dialling. Highlighting follows the latest
* broadcast, which does not confirm an incident or its resolution.
*/
/** @constant {string} Tint for aircraft broadcasting an emergency (not a priority). */
export const EMERGENCY_TINT_CSS = '#FF4444';
const SQUAWK_KINDS = { 7500: 'unlawful', 7600: 'nordo', 7700: 'general' };
const KINDS = {
general: { label: 'General emergency', severity: 'emergency' },
unlawful: { label: 'Unlawful interference', severity: 'emergency' },
nordo: { label: 'Radio failure', severity: 'emergency' },
downed: { label: 'Downed aircraft', severity: 'emergency' },
minfuel: { label: 'Minimum fuel', severity: 'priority' },
lifeguard: { label: 'Lifeguard (medical priority)', severity: 'priority' },
};
/**
* A Mode A code as its four octal digits, else null.
* @param {unknown} value
* @returns {string|null}
*/
export function normalizeSquawk(value) {
const code = String(value ?? '').trim();
return /^[0-7]{4}$/.test(code) ? code : null;
}
/**
* Emergency status from one observation. Higher severity wins; an ADS-B
* emergency wins a tie. A
* 7x00 squawk counts even when the ADS-B field says `none`.
* @param {{squawk?: unknown, emergency?: unknown}} [observation]
* @returns {{kind: string, label: string, severity: 'emergency'|'priority',
* squawk: string|null, source: 'ads-b'|'squawk'}|null} `source` is
* 'squawk' when the Mode A code itself names the status (so it can be
* quoted), else 'ads-b'.
*/
export function aircraftEmergency({ squawk, emergency } = {}) {
const code = normalizeSquawk(squawk);
const reported = String(emergency ?? '')
.trim()
.toLowerCase();
const fromAdsb = Object.hasOwn(KINDS, reported);
const squawkKind = SQUAWK_KINDS[code] ?? null;
const kind =
fromAdsb && (KINDS[reported].severity === 'emergency' || !squawkKind)
? reported
: squawkKind;
if (!kind) return null;
return {
kind,
...KINDS[kind],
squawk: code,
source: SQUAWK_KINDS[code] === kind ? 'squawk' : 'ads-b',
};
}
/**
* How a summary may phrase the status without flattening it into an incident:
* quote the code when the Mode A squawk itself names the status, otherwise
* attribute it to the ADS-B emergency field.
* @param {{kind?: string, squawk?: string|null, source?: string}|null|undefined} status
* @returns {string|null} e.g. 'broadcasting squawk 7700 (general emergency)'
* or 'ADS-B reports general emergency'; null when there is no status.
*/
export function emergencyBroadcastPhrase(status) {
if (!status || !Object.hasOwn(KINDS, status.kind)) return null;
const label = KINDS[status.kind].label.toLowerCase();
return status.source === 'squawk' && status.squawk
? `broadcasting squawk ${status.squawk} (${label})`
: `ADS-B reports ${label}`;
}
/**
* True when the aircraft is broadcasting an emergency (not a priority status).
* @param {{severity?: string}|null|undefined} status
* @returns {boolean}
*/
export function isEmergency(status) {
return status?.severity === 'emergency';
}
+95
View File
@@ -0,0 +1,95 @@
// src/data/aircraftEmergency.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
aircraftEmergency,
emergencyBroadcastPhrase,
isEmergency,
normalizeSquawk,
} from './aircraftEmergency.js';
test('the three ICAO emergency squawks map to their kinds', () => {
assert.equal(aircraftEmergency({ squawk: '7500' }).kind, 'unlawful');
assert.equal(aircraftEmergency({ squawk: '7600' }).kind, 'nordo');
const general = aircraftEmergency({ squawk: ' 7700 ' });
assert.deepEqual(general, {
kind: 'general',
label: 'General emergency',
severity: 'emergency',
squawk: '7700',
source: 'squawk',
});
});
test('ordinary codes, blanks and non-octal values are not emergencies', () => {
for (const squawk of ['1200', '7000', '2000', '', null, undefined, '7800'])
assert.equal(aircraftEmergency({ squawk }), null, String(squawk));
assert.equal(aircraftEmergency(), null);
assert.equal(normalizeSquawk('7800'), null);
assert.equal(normalizeSquawk(7700), '7700');
assert.equal(normalizeSquawk('770'), null);
});
test('the ADS-B emergency field wins, and `none` defers to the squawk', () => {
const minfuel = aircraftEmergency({ squawk: '4521', emergency: 'MinFuel' });
assert.equal(minfuel.kind, 'minfuel');
assert.equal(minfuel.severity, 'priority');
assert.equal(minfuel.source, 'ads-b');
assert.equal(minfuel.squawk, '4521');
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'none' }).source,
'squawk',
);
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'general' }).source,
'squawk',
'a code that names the status stays quotable',
);
assert.equal(
aircraftEmergency({ squawk: '1200', emergency: 'general' }).source,
'ads-b',
);
assert.equal(aircraftEmergency({ emergency: 'downed' }).kind, 'downed');
assert.equal(aircraftEmergency({ emergency: 'none' }), null);
assert.equal(aircraftEmergency({ emergency: 'reserved' }), null);
});
test('priority statuses are reported but are not emergencies', () => {
assert.equal(isEmergency(aircraftEmergency({ squawk: '7700' })), true);
assert.equal(
isEmergency(aircraftEmergency({ emergency: 'lifeguard' })),
false,
);
assert.equal(isEmergency(aircraftEmergency({ emergency: 'minfuel' })), false);
assert.equal(isEmergency(null), false);
});
test('the broadcast phrase quotes a naming code, else attributes ADS-B', () => {
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ squawk: '7700' })),
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(
emergencyBroadcastPhrase(
aircraftEmergency({ squawk: '1200', emergency: 'general' }),
),
'ADS-B reports general emergency',
);
assert.equal(
emergencyBroadcastPhrase(aircraftEmergency({ emergency: 'lifeguard' })),
'ADS-B reports lifeguard (medical priority)',
);
assert.equal(emergencyBroadcastPhrase(null), null);
assert.equal(emergencyBroadcastPhrase({ kind: 'reserved' }), null);
});
test('an emergency squawk outranks an ADS-B priority', () => {
assert.equal(
aircraftEmergency({ squawk: '7700', emergency: 'lifeguard' }).kind,
'general',
);
assert.equal(
aircraftEmergency({ squawk: '7500', emergency: 'minfuel' }).kind,
'unlawful',
);
});
+524
View File
@@ -0,0 +1,524 @@
import * as Cesium from 'cesium';
import { TRACKED_ICON_COLOR } from '../layers/military/policy.js';
import { createRendering as civilRendering } from '../layers/flights/rendering.js';
import { createRendering as militaryRendering } from '../layers/military/rendering.js';
import { createMilitarySnapshotRenderer } from '../layers/military/snapshotRenderer.js';
import { aircraftEmergency } from './aircraftEmergency.js';
// Source-to-voice provenance, status retention/clearing, contact-time contracts,
// and civil/military billboard, Cockpit and model tint regressions.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { openSkySnapshot, readsbSnapshot } from '../sources/live/index.js';
import { FlightRecords } from '../layers/flights/records.js';
import { MilitaryFlightRecords } from '../layers/military/records.js';
import { mapAnalystRecord as mapFlight } from './flights.js';
import { mapAnalystRecord as mapMilitary } from './militaryFlights.js';
import { createAnalystEngine } from './analystEngine.js';
import { normalizeAdsbLolPointResponse } from './adsbLolFallback.js';
import { createGevActionRunner } from '../voice/gevActions.js';
const NOW_MS = 1800000000000;
test('fallback status belongs to the first valid admitted aircraft row', () => {
const valid = { hex: 'a1b2c3', lat: 30, lon: -97, squawk: '1200' };
for (const status of [undefined, 'general', 'none']) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [
{ ...valid, lat: 100, emergency: 'general' },
{ ...valid, emergency: status },
{ ...valid, lat: null, emergency: 'general' },
{ ...valid, emergency: status === 'general' ? 'none' : 'general' },
],
});
const snapshot = openSkySnapshot(payload, { now: NOW_MS });
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.records[0].emergency, status);
assert.equal(snapshot.records[0].latitude, 30);
}
});
function civilStore() {
return new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
approxDistanceKm: () => 0,
});
}
const receiveContext = {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
};
test('real civil fallback preserves ADS-B emergency and priority, then clears', () => {
const store = civilStore();
const aircraft = {
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
seen: 3,
seen_pos: 1,
};
for (const [status, expectedKind, flagged] of [
['general', 'general', true],
['minfuel', 'minfuel', false],
['none', null, false],
]) {
const payload = normalizeAdsbLolPointResponse({
now: NOW_MS,
ac: [{ ...aircraft, emergency: status }],
});
assert.equal(
payload.states[0].length,
18,
'fallback vectors retain the OpenSky shape',
);
const [observation] = openSkySnapshot(payload, { now: NOW_MS }).records;
const result = mapFlight(
aircraft.hex,
store.receive(observation, receiveContext).meta,
);
assert.equal(result.emergency, flagged);
assert.equal(result.emergencyKind, expectedKind);
assert.equal(result.emergencySource, expectedKind ? 'ads-b' : null);
assert.equal(result.lastContactAtMs, NOW_MS - 3000);
}
});
test('changed status without contact time retains general contact, including voice projection', async () => {
const store = civilStore();
const [observation] = readsbSnapshot(
{
ac: [
{
hex: 'a1b2c3',
flight: 'TEST1',
lat: 30,
lon: -97,
alt_baro: 10000,
gs: 150,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS, now: NOW_MS },
).records;
store.receive(observation, receiveContext);
const meta = store.receive(
{ ...observation, contactTimeMs: null, squawk: '7600', emergency: 'none' },
receiveContext,
).meta;
const record = mapFlight(observation.id, meta);
assert.equal(record.emergencyKind, 'nordo');
assert.equal(record.lastContactAtMs, NOW_MS - 3000);
assert.equal(Object.hasOwn(record, 'observedAtMs'), false);
globalThis.window = globalThis.window || {
clearTimeout,
setTimeout,
requestIdleCallback: null,
};
const flights = {
id: 'flights',
source: 'fixture',
getStats: () => ({ count: 1 }),
getAnalystRecords: () => [record],
};
const runner = createGevActionRunner({
viewer: {
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
camera: {
moveEnd: { addEventListener() {} },
positionCartographic: {
height: 300000,
latitude: 0.52,
longitude: -1.71,
},
},
},
styleManager: {
getContextModeState: () => ({ mode: null, active: false }),
getCockpitState: () => ({ active: false }),
},
dataManager: {
layers: new Map([['flights', { module: flights }]]),
isEnabled: (id) => id === 'flights',
getAll: () => [
{ id: 'flights', name: 'Flights', enabled: true, stats: { count: 1 } },
],
},
});
try {
const result = await runner('analyst_query', {
layers: ['flights'],
scope: { kind: 'anywhere' },
filters: [{ field: 'emergency', op: 'eq', value: true }],
});
assert.equal(result.ok, true);
assert.equal(result.items[0].emergency, true);
assert.equal(result.items[0].lastContactAtMs, NOW_MS - 3000);
assert.equal(result.items[0].emergencySource, 'squawk');
assert.equal(
result.items[0].emergencyBroadcast,
'broadcasting squawk 7600 (radio failure)',
);
assert.equal(
Object.hasOwn(result.items[0], 'time'),
false,
'general contact is not a generic incident timestamp',
);
} finally {
runner.dispose?.();
}
});
// OpenSky /states/all: [icao24, callsign, origin_country, time_position,
// last_contact, lon, lat, baro_alt, on_ground, velocity, true_track,
// vertical_rate, sensors, geo_alt, squawk, spi, position_source, category].
const OPENSKY = {
time: NOW_MS / 1000,
states: [
[
'a1b2c3',
'SWA696 ',
'United States',
NOW_MS / 1000 - 6,
NOW_MS / 1000 - 3,
-97.67,
30.19,
3000,
false,
150,
180,
-5,
null,
3040,
'7700',
false,
0,
4,
],
],
};
// adsb.lol /v2/mil: readsb aircraft with `seen`/`seen_pos` ages in seconds.
const ADSB_LOL = {
ac: [
{
hex: 'ae01ce',
flight: 'RCH123 ',
t: 'C17',
lat: 31.1,
lon: -97.4,
alt_baro: 18000,
gs: 300,
track: 90,
squawk: '1200',
emergency: 'general',
seen: 1.5,
seen_pos: 2,
},
],
};
function civilAnalystRecord() {
const [row] = openSkySnapshot(OPENSKY, { now: NOW_MS }).records;
const store = new FlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
approxDistanceKm: () => 0,
});
const { meta } = store.receive(row, {
viewerLatDeg: null,
viewerLonDeg: null,
trackedId: null,
floorWarmPoints: [],
});
return mapFlight(row.id, meta);
}
function militaryAnalystRecord() {
const [row] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (altitude) => altitude,
});
const { meta } = store.receive(row, {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
});
return mapMilitary(row.id, meta);
}
test('OpenSky 7700 and adsb.lol emergency=general differ only in provenance', () => {
const civil = civilAnalystRecord();
const military = militaryAnalystRecord();
assert.equal(civil.emergency, true);
assert.equal(civil.emergencyKind, 'general');
assert.equal(civil.squawk, '7700');
assert.equal(civil.emergencySource, 'squawk');
assert.equal(
civil.emergencyBroadcast,
'broadcasting squawk 7700 (general emergency)',
);
assert.equal(civil.lastContactAtMs, NOW_MS - 3000, 'OpenSky last_contact');
assert.equal(military.emergency, true);
assert.equal(military.emergencyKind, 'general');
assert.equal(military.squawk, '1200', 'the code is not an emergency code');
assert.equal(military.emergencySource, 'ads-b');
assert.equal(military.emergencyBroadcast, 'ADS-B reports general emergency');
assert.equal(military.lastContactAtMs, NOW_MS - 1500, 'snapshot time - seen');
});
test('the analyst can filter emergencies by source', async () => {
const records = {
flights: [civilAnalystRecord()],
military: [militaryAnalystRecord()],
};
const engine = createAnalystEngine({
getRecords: (key) => records[key] || [],
resolveRegionRing: async () => null,
getViewContext: () => ({ lat: 30.5, lon: -97.5, viewRadiusKm: 500 }),
});
const query = (value) =>
engine.query({
layers: ['flights', 'military'],
scope: { kind: 'anywhere' },
filters: [
{ field: 'emergency', op: 'eq', value: true },
{ field: 'emergencySource', op: 'eq', value },
],
});
const bySquawk = await query('squawk');
assert.deepEqual(
bySquawk.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['a1b2c3', 'broadcasting squawk 7700 (general emergency)']],
);
const byAdsb = await query('ads-b');
assert.deepEqual(
byAdsb.items.map((item) => [item.icao24, item.emergencyBroadcast]),
[['ae01ce', 'ADS-B reports general emergency']],
);
});
test('military status changes retain only the latest known general contact', () => {
const store = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (v) => v,
});
const [observation] = readsbSnapshot(ADSB_LOL, {
observedAtMs: NOW_MS,
now: NOW_MS,
}).records;
const context = {
observedAtMs: NOW_MS,
floorWarmPoints: [],
modelOwnsVisual: false,
};
const unknown = mapMilitary(
observation.id,
store.receive({ ...observation, contactTimeMs: null }, context).meta,
);
assert.equal(unknown.lastContactAtMs, null);
store.receive(observation, context);
const changed = mapMilitary(
observation.id,
store.receive(
{
...observation,
contactTimeMs: null,
squawk: '7600',
emergency: 'none',
},
context,
).meta,
);
assert.equal(changed.emergencyKind, 'nordo');
assert.equal(changed.lastContactAtMs, NOW_MS - 1500);
});
test('fallback missing contact age retains known contact through the real converter', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
seen_pos: 1,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
const first = receive(row);
const second = receive({ ...row, seen: undefined, seen_pos: undefined });
assert.equal(second.lastContactEpochMs, first.lastContactEpochMs);
});
test('one omitted ADS-B status poll is retained; explicit none and changed squawk clear immediately', () => {
const store = civilStore();
const row = {
hex: 'a1b2c3',
lat: 30,
lon: -97,
squawk: '1200',
emergency: 'general',
seen: 3,
};
const receive = (row) =>
store.receive(
openSkySnapshot(
normalizeAdsbLolPointResponse({ now: NOW_MS, ac: [row] }),
{ now: NOW_MS },
).records[0],
receiveContext,
).meta;
assert.equal(receive(row).emergency.kind, 'general');
assert.equal(
receive({ ...row, emergency: undefined }).emergency?.kind,
'general',
);
assert.equal(receive({ ...row, emergency: undefined }).emergency, null);
receive(row);
assert.equal(receive({ ...row, emergency: 'none' }).emergency, null);
receive(row);
assert.equal(
receive({ ...row, emergency: undefined, squawk: '2000' }).emergency,
null,
);
});
function renderHarness(factory) {
const flightState = { records: { data: new Map() }, _trackedIcao: null };
const rendering = factory({
flightState,
services: {
militaryRegistry: { isMilitaryIcao: (id) => id === 'mil' },
aircraftPresentation: {},
camera: {},
focus: {},
recession: {},
},
parts: {},
});
return { flightState, rendering };
}
test('tint priorities cover normal, priority, emergency, tracking and Cockpit dots', () => {
for (const factory of [civilRendering, militaryRendering]) {
const { flightState, rendering } = renderHarness(factory);
const normal = rendering._fleetBillboardColor('civil', {});
assert.equal(
rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ emergency: 'minfuel' }),
}),
normal,
);
const red = rendering._fleetBillboardColor('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.ok(red.red > 0.9 && red.green < 0.5);
flightState.records.data.set('civil', {
emergency: aircraftEmergency({ squawk: '7700' }),
});
assert.equal(rendering._modelColor('civil'), red);
if (rendering._cockpitContactColor)
assert.equal(rendering._cockpitContactColor('civil'), red);
flightState._trackedIcao = 'civil';
assert.equal(
rendering._modelColor('civil'),
factory === civilRendering ? Cesium.Color.CYAN : TRACKED_ICON_COLOR,
);
flightState._trackedIcao = null;
flightState.records.data.set('civil', { emergency: null });
assert.equal(rendering._modelColor('civil'), normal);
}
});
test('GERMAN: an existing untracked military billboard repaints each poll without alpha loss', () => {
const { flightState, rendering } = renderHarness(militaryRendering);
const bb = {
color: rendering._fleetBillboardColor('abc123').withAlpha(0.65),
};
Object.assign(flightState, {
_billboards: new Map([['abc123', bb]]),
_models: new Map(),
_positionHistory: new Map(),
_cullPositions: new Map(),
_groundSnap: { forget() {} },
_cockpitContactMode: false,
});
const records = new MilitaryFlightRecords({
geoidHeight: () => 0,
cachedGroundFloor: () => null,
floorAltitudeM: (value) => value,
});
flightState.records = records;
const apply = createMilitarySnapshotRenderer({
flightState,
records,
groundFloor: { warmGroundFloor() {}, cachedGroundFloor: () => null },
meshFloor: { sampleMeshFloorCells() {} },
militaryRegistry: { registerMilitaryIcaos() {} },
rendering: {
...rendering,
_syncModelToClass() {},
_applyFleetBillboardPresentation() {},
},
motion: {},
tracking: {},
queries: {},
});
for (const [emergency, red] of [
['general', true],
['none', false],
['general', true],
]) {
const snapshot = readsbSnapshot(
{
ac: [
{
hex: 'abc123',
lat: 30,
lon: -97,
alt_baro: 10000,
squawk: '1200',
emergency,
seen_pos: 1,
},
],
},
{ observedAtMs: NOW_MS },
);
apply(snapshot, {});
assert.equal(bb.color.green < 0.5, red);
assert.equal(bb.color.alpha, 0.65);
}
});
+5 -2
View File
@@ -242,7 +242,7 @@ export function applyAircraftBillboardTreatment({
}
/**
* Apply the already-composed aircraft alpha to an ambient glTF model without
* Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without
* disturbing Cesium's existing MIX/colorBlendAmount presentation semantics.
* @param {object} input
* @param {object} input.model
@@ -261,7 +261,10 @@ export function applyAircraftModelTreatment({
if (!model || !baseColor || !Number.isFinite(alpha)) return 0;
if (
Number.isFinite(model.color?.alpha) &&
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon
Math.abs(model.color.alpha - alpha) <= tuning.writeEpsilon &&
model.color.red === baseColor.red &&
model.color.green === baseColor.green &&
model.color.blue === baseColor.blue
)
return 0;
model.color = baseColor.withAlpha(alpha);
+12
View File
@@ -142,3 +142,15 @@ test('ambient model presentation receives composed alpha without changing blend
assert.equal(model.colorBlendAmount, 0.9);
assert.equal(applyAircraftModelTreatment({ model, baseColor: color, alpha: 0.2, params }), 0);
});
test('model hue changes at constant alpha, then clears without redundant writes', () => {
const color = (red,green,blue) => ({red,green,blue,withAlpha(alpha){return {red,green,blue,alpha};}});
const model = {color:color(1,1,1).withAlpha(0.65)};
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),1);
assert.equal(model.color.green,0.2);
assert.equal(model.color.alpha,0.65);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,0.2,0.2),alpha:0.65}),0);
assert.equal(applyAircraftModelTreatment({model,baseColor:color(1,1,1),alpha:0.65}),1);
assert.equal(model.color.green,1);
});
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { directionToHeading } from './directionText.js';
test('conflicting facing directions cannot manufacture one heading', () => {
for (const bare of [false, true]) {
for (const value of ['NB / SB', 'EASTBOUND WESTBOUND', 'NE SW'])
assert.ok(Number.isNaN(directionToHeading(value, bare)), value);
assert.equal(directionToHeading('NB northbound', bare), 0);
assert.equal(directionToHeading('IH-35 SOUTHBOUND AT 15TH', bare), 180);
}
assert.ok(Number.isNaN(directionToHeading('North / East', true)));
assert.equal(directionToHeading('West / SR-24', true), 270);
});
test('travel tokens retain precedence over quadrant streets and bare words', () => {
for (const [text, heading] of [
['I-5 NB at NE 45th St', 0],
['SR 520 EB at NE 148th', 90],
['US 13 SB @ SW Front St', 180],
]) {
for (const bare of [false, true])
assert.equal(directionToHeading(text, bare), heading);
}
assert.equal(
directionToHeading('Hwy 401 east of Keele St - Westbound', true),
270,
);
assert.equal(directionToHeading('NE near West St', true), 45);
assert.ok(Number.isNaN(directionToHeading('NB SB near NE St', true)));
});
+15
View File
@@ -0,0 +1,15 @@
import * as Cesium from 'cesium';
/** Camera range with breathing room in both viewport dimensions. */
export function rangeForBoundingSphere(viewer, radius) {
const frustum = viewer.camera?.frustum;
const verticalFov = Number(frustum?.fov) || Cesium.Math.toRadians(60);
const aspectRatio = Math.max(0.5, Number(frustum?.aspectRatio) || 1);
const horizontalFov = 2 * Math.atan(Math.tan(verticalFov / 2) * aspectRatio);
const limitingFov = Math.min(verticalFov, horizontalFov);
// Target 57% sphere occupancy so perspective still leaves at least 40%
// measured roof-to-base breathing room in ordinary oblique building views.
const occupiedViewportFraction = 0.57;
const desiredAngularRadius = (limitingFov * occupiedViewportFraction) / 2;
return (radius / Math.sin(desiredAngularRadius)) * 1.05;
}
+391
View File
@@ -0,0 +1,391 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadNycSourcesFromOpenData,
nycCameraIsOnline,
nycCameraName,
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
NYC_IMAGE_ORIGIN,
NYC_IMAGE_URL,
NYC_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyNycCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
const row = (overrides = {}) => ({
id: MIDTOWN_ID,
name: 'Central Park West @ 86 St',
latitude: 40.785302,
longitude: -73.969353,
area: 'Manhattan',
isOnline: 'true',
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
...overrides,
});
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
const source = nycCameraToSource(row());
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
assert.equal(source.name, 'Central Park West @ 86 St');
assert.equal(source.city, 'Manhattan');
assert.equal(source.cityId, 'nyc');
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
assert.equal(source.lat, 40.785302);
assert.equal(source.lon, -73.969353);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'nyc-dot-tmc');
assert.equal(
source.license,
'Public NYC DOT Traffic Management Center traffic camera',
);
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
});
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
const source = nycCameraToSource(
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
assert.equal(
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
`nyc-${MIDTOWN_ID}`,
);
assert.equal(
nycCameraToSource(row({ isOnline: true })).id,
`nyc-${MIDTOWN_ID}`,
);
// Austin: a plausible lat/lon, but not a New York camera.
assert.equal(
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
null,
);
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
assert.equal(nycCameraToSource(null), null);
});
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
const mixed = nycCameraToSource(
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
);
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
assert.equal(mixed.city, 'New York');
assert.equal(
mixed.url,
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
'the registered frame path keeps the catalog UUID spelling',
);
});
test('a nameless row still gets a label', () => {
assert.equal(
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
'FDR Drive @ 23 St',
);
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
assert.equal(nycCameraIsOnline('true'), true);
assert.equal(nycCameraIsOnline('false'), false);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
const long = nycCameraToSource(
row({
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
}),
);
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BROOKLYN'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
name: 'Central Park West @ 86 St (duplicate)',
latitude: 40.758,
longitude: -73.9855,
}),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
}),
row({ isOnline: 'false', name: 'Offline camera' }),
row({ id: 'https://evil.example/x' }),
]);
});
const cameras = await loadNycSourcesFromOpenData();
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
);
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
assert.equal(cameras[1].city, 'Brooklyn');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_NYC_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
else process.env.CCTV_NYC_MAX_SOURCES = saved;
});
process.env.CCTV_NYC_MAX_SOURCES = '8';
const rows = [
// Far southern Staten Island, listed first so only distance can keep them.
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
row({
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -74.24,
area: 'Staten Island',
name: `SI ${i}`,
}),
),
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
row({
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -73.9855,
area: 'Manhattan',
name: `Midtown ${i}`,
}),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadNycSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.city),
[
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Staten Island',
'Staten Island',
],
'Midtown fills first; the Staten Island anchor still keeps a share',
);
});
/**
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
* other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === NYC_CAMERAS_URL) {
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
}),
]);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
* leaves it unset), restoring the environment afterwards. */
const runCatalogWithNycFlag = async (t, flag) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
else process.env.CCTV_NYC_ENABLED = flag;
return await runCatalogWithMockedUpstreams(t);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
assert.ok(
requested.includes(NYC_CAMERAS_URL),
'the catalog refresh invokes the NYC loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
'NYC cameras reach the served catalog through the registered lane',
);
});
// NYC DOT requires a data-sharing agreement for this feed, so an install that
// has not opted in must never contact it.
for (const [label, flag] of [
['an unset CCTV_NYC_ENABLED', undefined],
['an empty CCTV_NYC_ENABLED', ''],
['CCTV_NYC_ENABLED=0', '0'],
['CCTV_NYC_ENABLED=true', 'true'],
]) {
test(`${label} makes no NYC request`, async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('nyctmc.org')),
false,
'the lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc'),
[],
);
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyNycCoordinate(0, 0), false);
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
});
+15 -1
View File
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
return true;
}
/** NYC DOT asks every application that uses its data to carry this
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
export const NYC_DOT_DISCLAIMER =
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information&#39;s suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
export const DATA_CREDITS = [
// ── Live sources ────────────────────────────────────────────────
{
@@ -104,7 +111,7 @@ export const DATA_CREDITS = [
{
key: 'adsblol',
html:
'Military flights, aircraft traces &amp; bounded regional flight fallback: ' +
'Military flights, aircraft traces, bounded regional flight fallback &amp; GNSS navigation integrity: ' +
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
@@ -247,6 +254,13 @@ export const DATA_CREDITS = [
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
},
{
key: 'nyc-cctv',
html:
'CCTV cameras &amp; frames (New York City, when enabled): ' +
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
NYC_DOT_DISCLAIMER,
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+9 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
assert.equal(a.credits.size, 0);
assert.equal(b.credits.size, 1);
});
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
assert.ok(credit, 'NYC DOT camera frames must be credited');
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
+28 -16
View File
@@ -28,22 +28,34 @@ export function directionToHeading(value, allowBare = false) {
.trim()
.toUpperCase();
if (!text) return NaN;
// Explicit travel/intercardinal forms — safe on free-form text (a street
// name almost never contains "NORTHBOUND" or a lone "NB" token).
if (/\bNORTHBOUND\b|\bNB\b/.test(text)) return 0;
if (/\bSOUTHBOUND\b|\bSB\b/.test(text)) return 180;
if (/\bEASTBOUND\b|\bEB\b/.test(text)) return 90;
if (/\bWESTBOUND\b|\bWB\b/.test(text)) return 270;
if (/\bNORTHEAST\b|\bNE\b/.test(text)) return 45;
if (/\bNORTHWEST\b|\bNW\b/.test(text)) return 315;
if (/\bSOUTHEAST\b|\bSE\b/.test(text)) return 135;
if (/\bSOUTHWEST\b|\bSW\b/.test(text)) return 225;
// Bare cardinals — dedicated direction fields only.
if (allowBare) {
if (/\bNORTH\b/.test(text)) return 0;
if (/\bSOUTH\b/.test(text)) return 180;
if (/\bEAST\b/.test(text)) return 90;
if (/\bWEST\b/.test(text)) return 270;
const tiers = [
[
[/\bNORTHBOUND\b|\bNB\b/, 0],
[/\bSOUTHBOUND\b|\bSB\b/, 180],
[/\bEASTBOUND\b|\bEB\b/, 90],
[/\bWESTBOUND\b|\bWB\b/, 270],
],
[
[/\bNORTHEAST\b|\bNE\b/, 45],
[/\bNORTHWEST\b|\bNW\b/, 315],
[/\bSOUTHEAST\b|\bSE\b/, 135],
[/\bSOUTHWEST\b|\bSW\b/, 225],
],
];
if (allowBare)
tiers.push([
[/\bNORTH\b/, 0],
[/\bSOUTH\b/, 180],
[/\bEAST\b/, 90],
[/\bWEST\b/, 270],
]);
for (const forms of tiers) {
const headings = new Set();
for (const [pattern, heading] of forms) {
if (pattern.test(text)) headings.add(heading);
}
if (headings.size)
return headings.size === 1 ? headings.values().next().value : NaN;
}
return NaN;
}
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { acquisitionMsUtc } from './firmsCsv.js';
test('FIRMS acquisition dates cannot roll into a different calendar day', () => {
for (const [date, time] of [
['2026-02-30', '1200'],
['2026-04-31', '1200'],
['2026-13-01', '1200'],
['2026-07-16junk', '1200'],
['2026-07-16', '2400'],
['2026-07-16', '1260'],
['2026-07-16', null],
['2026-07-16', ''],
]) {
assert.ok(Number.isNaN(acquisitionMsUtc(date, time)), `${date}:${time}`);
}
for (const [date, time, expected] of [
['2024-02-29', '45', Date.UTC(2024, 1, 29, 0, 45)],
['2026-07-16', '0', Date.UTC(2026, 6, 16)],
]) {
assert.equal(acquisitionMsUtc(date, time), expected);
}
});
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseFirmsCsv } from './firmsCsv.js';
import { adaptFirmsRecords } from './firmsAdapt.js';
test('missing positions do not become fire detections at zero coordinates', () => {
const csv =
'latitude,longitude,acq_date,acq_time,confidence,frp\n,1,2026-07-16,1200,n,1\n1, ,2026-07-16,1200,n,1\n95,1,2026-07-16,1200,n,1\n1,181,2026-07-16,1200,n,1\n0,0,2026-07-16,1200,n,1\n-90,-180,2026-07-16,1200,n,1';
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[0, 0],
[-90, -180],
],
);
const records = [
{ lat: null, lon: 1 },
{ lat: '', lon: 1 },
{ lat: false, lon: 1 },
{ lat: 95, lon: 1 },
{ lat: 1, lon: 181 },
{ lat: '0', lon: 0 },
{ lat: -90, lon: -180 },
];
assert.deepEqual(
adaptFirmsRecords(records).map((r) => [r.index, r.lat, r.lon]),
[
[0, 0, 0],
[1, -90, -180],
],
);
});
test('both axes keep their exact bounds and drop values just past them', () => {
const row = (lat, lon) => `${lat},${lon},2026-07-16,1200,n,1`;
const csv = [
'latitude,longitude,acq_date,acq_time,confidence,frp',
row(90, 180),
row(-90, -180),
row(90.000001, 0),
row(-90.000001, 0),
row(0, 180.000001),
row(0, -180.000001),
row(' ', 5),
row(5, '\t'),
].join('\n');
assert.deepEqual(
parseFirmsCsv(csv).map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
const kept = adaptFirmsRecords([
{ lat: 90, lon: 180 },
{ lat: '-90', lon: '-180' },
{ lat: -90.000001, lon: 0 },
{ lat: 0, lon: -180.000001 },
{ lat: 5, lon: ' ' },
{ lat: 5, lon: undefined },
{ lat: [5], lon: 5 },
]);
assert.deepEqual(
kept.map((r) => [r.lat, r.lon]),
[
[90, 180],
[-90, -180],
],
);
});
+19 -3
View File
@@ -7,8 +7,8 @@
*/
/**
* Map proxy fire records into internal fire records. Records with
* non-finite coordinates are skipped; `index` is the post-skip position
* Map proxy fire records into internal fire records. Records with missing,
* non-numeric or out-of-range (WGS84) coordinates are skipped; `index` is the post-skip position
* (it keys pick ids and context-store ids, so it must stay sequential).
* `contextEntity`/`position` start null and are lazily filled by the layer.
* @param {?Array<Object>} records - /api/firms `fires` array.
@@ -21,7 +21,15 @@ export function adaptFirmsRecords(records) {
for (const record of records) {
const lat = Number(record?.lat);
const lon = Number(record?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
if (
!hasCoordinateValue(record?.lat) ||
!hasCoordinateValue(record?.lon) ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
fires.push({
index: fires.length,
lat,
@@ -110,3 +118,11 @@ function finiteNumber(value) {
const number = Number(value);
return Number.isFinite(number) ? number : 0;
}
// Same rule as parseFirmsCsv: a blank or non-numeric value is not a zero.
function hasCoordinateValue(value) {
return (
typeof value === 'number' ||
(typeof value === 'string' && value.trim() !== '')
);
}
+18 -2
View File
@@ -105,7 +105,17 @@ export function parseFirmsCsv(text) {
if (parts.length < header.length) continue; // malformed row — skip
const lat = Number(parts[iLat]);
const lon = Number(parts[iLon]);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Blank or out-of-range positions are dropped, never read as 0 (see
// hasCoordinateValue in firmsAdapt.js for the proxy-record side).
if (
!parts[iLat].trim() ||
!parts[iLon].trim() ||
!Number.isFinite(lat) ||
!Number.isFinite(lon) ||
Math.abs(lat) > 90 ||
Math.abs(lon) > 180
)
continue;
records.push({
lat,
@@ -153,7 +163,13 @@ export function acquisitionMsUtc(acqDate, acqTime) {
minutes > 59
)
return NaN;
return Date.UTC(year, month - 1, day, hours, minutes);
const ms = Date.UTC(year, month - 1, day, hours, minutes);
const parsed = new Date(ms);
return parsed.getUTCFullYear() === year &&
parsed.getUTCMonth() === month - 1 &&
parsed.getUTCDate() === day
? ms
: NaN;
}
/**
+6
View File
@@ -79,6 +79,12 @@ test('flights analyst record: full record maps every contract field', () => {
aircraftClass: 'airliner',
originCountry: 'United States',
operator: 'Southwest Airlines',
squawk: null,
emergency: false,
emergencyKind: null,
emergencySource: null,
emergencyBroadcast: null,
lastContactAtMs: null,
routeOrigin: 'AUS',
routeDestination: 'LAX',
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
// Inspect encoded headers before allowing the browser to decode local images.
export const MAX_IMPORT_IMAGE_PIXELS = 16 * 1024 * 1024;
const MAX_SIDE = 8192;
/** Width and height from a supported raster header; no image is decoded. */
export function importImageSize(bytes) {
const v = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const ascii = (at, n) => String.fromCharCode(...bytes.subarray(at, at + n));
const u24 = (at) => bytes[at] | (bytes[at + 1] << 8) | (bytes[at + 2] << 16);
if (
bytes.length >= 24 &&
v.getUint32(0) === 0x89504e47 &&
ascii(12, 4) === 'IHDR'
)
return [v.getUint32(16), v.getUint32(20)];
if (bytes.length >= 10 && /^GIF8[79]a$/.test(ascii(0, 6)))
return [v.getUint16(6, true), v.getUint16(8, true)];
if (bytes.length >= 26 && ascii(0, 2) === 'BM') {
if (v.getUint32(14, true) === 12)
return [v.getUint16(18, true), v.getUint16(20, true)];
return [Math.abs(v.getInt32(18, true)), Math.abs(v.getInt32(22, true))];
}
if (bytes.length >= 30 && ascii(0, 4) === 'RIFF' && ascii(8, 4) === 'WEBP') {
const kind = ascii(12, 4);
if (kind === 'VP8X') return [1 + u24(24), 1 + u24(27)];
if (kind === 'VP8 ' && u24(23) === 0x2a019d)
return [v.getUint16(26, true) & 0x3fff, v.getUint16(28, true) & 0x3fff];
if (kind === 'VP8L' && bytes[20] === 0x2f) {
const bits = v.getUint32(21, true);
return [(bits & 0x3fff) + 1, ((bits >>> 14) & 0x3fff) + 1];
}
}
if (bytes.length >= 4 && v.getUint16(0) === 0xffd8) {
for (let at = 2; at + 4 <= bytes.length;) {
if (bytes[at++] !== 0xff) break;
while (bytes[at] === 0xff) at++;
const marker = bytes[at++];
if (marker === 0xd9 || marker === 0xda) break;
if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue;
const length = v.getUint16(at);
if (length < 2 || at + length > bytes.length) break;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd,
0xce, 0xcf,
].includes(marker) &&
length >= 8
)
return [v.getUint16(at + 5), v.getUint16(at + 3)];
at += length;
}
}
return null;
}
/** Charge each distinct encoded image once against the file's decoded budget. */
export function createImportImageBudget() {
const seen = new Set();
let pixels = 0;
return (key, bytes) => {
if (seen.has(key)) return;
const size = importImageSize(bytes);
if (
!size ||
size.some((n) => !Number.isInteger(n) || n <= 0 || n > MAX_SIDE) ||
pixels + size[0] * size[1] > MAX_IMPORT_IMAGE_PIXELS
)
throw new Error(
'Local images exceed the decoded image limit or have an unreadable header.',
);
pixels += size[0] * size[1];
seen.add(key);
};
}
+128
View File
@@ -0,0 +1,128 @@
import * as Cesium from 'cesium';
const STYLE = [
'image',
'scale',
'rotation',
'alignedAxis',
'horizontalOrigin',
'verticalOrigin',
'color',
'eyeOffset',
'pixelOffset',
'width',
'height',
'scaleByDistance',
'translucencyByDistance',
'pixelOffsetScaleByDistance',
'distanceDisplayCondition',
'sizeInMeters',
'disableDepthTestDistance',
'imageSubRegion',
];
class ImportedBillboards extends Cesium.BillboardCollection {
update(frameState) {
// Height queries should find the map, never another imported marker.
// Ordinary click picks and rendered screenshots still include the pins.
if (frameState.passes.pick && frameState.passes.offscreen) return;
super.update(frameState);
}
}
/** Own static imported pins without a per-frame Entity visualizer walk. */
export function createImportedPins(dataSource, viewer) {
const all = dataSource.entities.values.slice();
const now = Cesium.JulianDate.now();
const pins = all.filter((entity) => entity.billboard && entity.position);
if (!pins.length) return { mount() {}, sync() {}, destroy() {} };
const collection = new ImportedBillboards({ scene: viewer.scene });
const billboards = [];
try {
for (const entity of pins) {
const position = entity.position.getValue(now);
if (
!position ||
![position.x, position.y, position.z].every(Number.isFinite)
)
throw new Error('Invalid imported point position.');
const options = {
id: entity,
position,
show:
entity.isShowing && entity.billboard.show?.getValue(now) !== false,
heightReference: Cesium.HeightReference.NONE,
};
for (const key of STYLE) {
const value = entity.billboard[key]?.getValue(now);
if (value !== undefined) options[key] = value;
}
billboards.push(collection.add(options));
}
} catch (error) {
collection.destroy();
throw error;
}
// Rebuild once before the source is added to DataSourceDisplay. Removing
// one entity at a time would itself make this transition quadratic.
dataSource.entities.suspendEvents();
try {
dataSource.entities.removeAll();
for (const entity of all)
if (!entity.billboard || !entity.position)
dataSource.entities.add(entity);
} finally {
dataSource.entities.resumeEvents();
}
Object.defineProperty(dataSource, 'importedEntities', {
value: all,
configurable: true,
});
Object.defineProperty(dataSource, 'importedBillboards', {
value: collection,
configurable: true,
});
let show = dataSource.show,
mounted = false,
destroyed = false;
Object.defineProperty(dataSource, 'show', {
configurable: true,
get: () => show,
set(value) {
show = Boolean(value);
dataSource.entities.show = show;
collection.show = show;
},
});
collection.show = show;
return {
mount() {
if (destroyed || mounted) return;
viewer.scene.primitives?.add(collection);
mounted = true;
},
sync() {
if (destroyed) return;
for (let i = 0; i < pins.length; i++) {
billboards[i].position = pins[i].position.getValue(now);
billboards[i].show =
pins[i].isShowing && pins[i].billboard.show?.getValue(now) !== false;
}
viewer.scene.requestRender?.();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (viewer.trackedEntity && pins.includes(viewer.trackedEntity))
viewer.trackedEntity = undefined;
if (viewer.selectedEntity && pins.includes(viewer.selectedEntity))
viewer.selectedEntity = undefined;
if (mounted && !viewer.scene.primitives?.isDestroyed?.())
viewer.scene.primitives?.remove(collection);
if (!collection.isDestroyed()) collection.destroy();
all.length = 0;
pins.length = 0;
billboards.length = 0;
},
};
}
+429
View File
@@ -0,0 +1,429 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gnssIntegrityAnchor,
gnssIntegrityProxy,
gnssRetryCooldownMs,
} from '../../server/providers/gnssIntegrity.js';
const snapshot = {
now: 4_000_000_000_000,
ac: [
{
hex: '4ca7b5',
type: 'adsb_icao',
version: 2,
alt_baro: 36000,
lat: 50.1,
lon: 19.9,
nic: 0,
nac_p: 0,
seen_pos: 1,
flight: 'RYR1AB ',
r: 'EI-ABC',
},
{ hex: '000001', type: 'mlat', lat: 50, lon: 20, nic: 0, nac_p: 0 },
],
};
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = gnssIntegrityProxy({ monotonicNow: options.now, ...options });
assert.equal(plugin.name, 'gnss-integrity');
plugin[hook]({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/gnss-integrity');
handler = callback;
},
},
});
return async (url = '/?lat=50&lon=20', method = 'GET', peer = 'local') => {
const res = {
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
end(body) {
this.body = JSON.parse(body);
},
};
await handler({ url, method, socket: { remoteAddress: peer } }, res);
return res;
};
}
test('anchors snap to whole degrees and reject missing or out-of-range values', () => {
assert.deepEqual(gnssIntegrityAnchor('/?lat=50.44&lon=19.51'), {
lat: 50,
lon: 20,
});
assert.equal(gnssIntegrityAnchor('/?lat=91&lon=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0&lon=-181'), null);
assert.equal(gnssIntegrityAnchor('/?lat=0'), null);
assert.equal(gnssIntegrityAnchor('/?lat=abc&lon=1'), null);
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fetches the fixed adsb.lol origin and returns only integrity facts`, async () => {
const calls = [];
const request = install(
{
now: () => 1234,
fetchImpl: async (url, options) => {
calls.push(url);
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.redirect, 'error');
return Response.json(snapshot);
},
},
hook,
);
const res = await request(
'/?lat=50.2&lon=19.8&url=https://invalid.example',
);
assert.equal(res.status, 200);
assert.deepEqual(calls, ['https://api.adsb.lol/v2/lat/50/lon/20/dist/250']);
assert.deepEqual(res.body, {
fetchedAt: 1234,
ageMs: 0,
anchor: { lat: 50, lon: 20 },
radiusNm: 250,
classifier: 'gev-nic-nacp-v2',
rows: [
{
hex: '4ca7b5',
lat: 50.1,
lon: 19.9,
nic: 0,
nacp: 0,
gpsLost: false,
degraded: true,
},
],
});
assert.equal(res.headers['Cache-Control'], 'no-store');
});
}
test('snapshots are cached per anchor and concurrent reads share one upstream call', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return Response.json(snapshot);
},
});
await Promise.all([request(), request('/?lat=50.3&lon=20.2')]);
assert.equal(calls, 1);
clock = 59_000;
const cached = await request();
assert.equal(calls, 1);
assert.equal(cached.body.fetchedAt, 0);
assert.equal(
cached.body.ageMs,
59_000,
'a cached answer reports its age on the server clock',
);
clock = 61_000;
await request();
assert.equal(calls, 2);
await request('/?lat=10&lon=10');
assert.equal(calls, 3);
});
test('an upstream failure serves the last snapshot as stale, else 502', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 120_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(
stale.body.fetchedAt,
0,
'a stale answer keeps the time it was observed',
);
assert.equal(stale.body.ageMs, 120_000);
assert.equal(stale.headers['X-Data-Stale'], 'true');
const missing = await request('/?lat=-10&lon=-10');
assert.equal(missing.status, 502);
assert.deepEqual(missing.body, { error: 'gnss_integrity_unavailable' });
});
test('a stale snapshot older than the layer window is no longer served', async () => {
let clock = 0;
let fail = false;
const request = install({
now: () => clock,
fetchImpl: async () =>
fail ? new Response('down', { status: 503 }) : Response.json(snapshot),
});
await request();
fail = true;
clock = 30 * 60_000;
assert.equal((await request()).status, 200);
clock = 30 * 60_000 + 1;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'gnss_integrity_unavailable' });
});
test('an upstream 429 starts a cooldown with no further upstream calls', async () => {
let clock = 0;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return new Response('slow down', {
status: 429,
headers: { 'retry-after': '90' },
});
},
});
const first = await request();
assert.equal(first.status, 429);
assert.equal(first.headers['Retry-After'], '90');
clock = 30_500;
const cooling = await request('/?lat=1&lon=1');
assert.equal(cooling.status, 429);
// The browser is told the remaining shared cooldown, rounded up.
assert.equal(cooling.headers['Retry-After'], '60');
assert.equal(calls, 1);
clock = 91_000;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
});
test('Retry-After accepts sane seconds or HTTP dates up to one hour', () => {
const nowMs = Date.parse('2026-09-29T12:00:00Z');
assert.equal(gnssRetryCooldownMs('30', nowMs), 30_000);
assert.equal(gnssRetryCooldownMs('1', nowMs), 5_000);
assert.equal(gnssRetryCooldownMs('3600', nowMs), 3_600_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 13:00:00 GMT', nowMs),
3_600_000,
);
assert.equal(gnssRetryCooldownMs('1e309', nowMs), 60_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 12:00:45 GMT', nowMs),
45_000,
);
// Garbage, a past date or no header fall back to the default minute.
assert.equal(gnssRetryCooldownMs('soon', nowMs), 60_000);
assert.equal(
gnssRetryCooldownMs('Tue, 29 Sep 2026 11:00:00 GMT', nowMs),
60_000,
);
assert.equal(gnssRetryCooldownMs(null, nowMs), 60_000);
});
test('overlapping 429s preserve the longest shared deadline', async () => {
let clock = 0,
calls = 0;
const pending = [];
const request = install({
now: () => clock,
fetchImpl: () => {
calls += 1;
return new Promise((resolve) => pending.push(resolve));
},
});
const long = request('/?lat=1&lon=0'),
short = request('/?lat=2&lon=0');
await new Promise((resolve) => setImmediate(resolve));
pending[0](
new Response('', { status: 429, headers: { 'Retry-After': '3600' } }),
);
assert.equal((await long).headers['Retry-After'], '3600');
clock = 1000;
pending[1](
new Response('', { status: 429, headers: { 'Retry-After': '5' } }),
);
assert.equal((await short).headers['Retry-After'], '3599');
clock = 7000;
assert.equal((await request('/?lat=3&lon=0')).status, 429);
assert.equal(calls, 2);
clock = 3_600_001;
const resumed = request('/?lat=3&lon=0');
await new Promise((resolve) => setImmediate(resolve));
pending[2](Response.json(snapshot));
assert.equal((await resumed).status, 200);
assert.equal(calls, 3);
});
test('a cached anchor keeps serving its last snapshot as stale during a cooldown', async () => {
let clock = 0;
let limited = false;
let calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls += 1;
return limited
? new Response('slow down', { status: 429 })
: Response.json(snapshot);
},
});
await request();
limited = true;
clock = 61_000;
// Another anchor trips the shared cooldown…
assert.equal((await request('/?lat=5&lon=5')).status, 429);
clock = 62_000;
// …and the cached anchor is answered from cache without an upstream call.
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.rows.length, 1);
assert.equal(calls, 2);
});
test('an oversized upstream body is refused as 502 without being parsed', async () => {
const request = install({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(9 * 1024 * 1024) },
}),
});
const res = await request();
assert.equal(res.status, 502);
assert.deepEqual(res.body, { error: 'gnss_integrity_unavailable' });
});
test('the route rejects other methods, paths, bad anchors, a malformed feed and floods', async () => {
const request = install({
fetchImpl: async () => Response.json({ nope: true }),
});
assert.equal((await request('/', 'POST')).status, 405);
assert.equal((await request('/other?lat=1&lon=1')).status, 404);
assert.equal((await request('/?lat=1')).status, 400);
assert.equal((await request('/?lat=1&lon=1')).status, 502);
const flood = install({ fetchImpl: async () => Response.json(snapshot) });
let limited = 0;
for (let i = 0; i < 40; i += 1)
if ((await flood(`/?lat=${i}&lon=1`, 'GET', 'flood')).status === 429)
limited += 1;
assert.ok(limited > 0);
});
test('GERMAN: pathological retry deadlines are bounded', () => {
const at = Date.parse('2026-10-14T12:00:00Z');
assert.ok(gnssRetryCooldownMs('1e305', at) <= 3600000);
assert.equal(gnssRetryCooldownMs('9999999999999999999999', at), 3600000);
assert.equal(
gnssRetryCooldownMs('Wed, 14 Oct 2099 12:00:00 GMT', at),
3600000,
);
assert.equal(gnssRetryCooldownMs('0x10', at), 60000);
});
test('GERMAN: 5xx, network timeouts and malformed JSON share a short cooldown', async () => {
for (const fail of [
async () => new Response('', { status: 503 }),
async () => {
throw new DOMException('timeout', 'TimeoutError');
},
async () => new Response('bad JSON'),
]) {
let clock = 0,
calls = 0;
const request = install({
now: () => clock,
fetchImpl: async () => {
calls++;
return fail();
},
});
assert.equal((await request()).status, 502);
clock = 14000;
assert.equal((await request('/?lat=1&lon=1')).status, 502);
assert.equal(calls, 1);
clock = 15001;
await request('/?lat=2&lon=2');
assert.equal(calls, 2);
}
});
test('GERMAN: source timestamp dates evidence; frozen old snapshots are rejected', async () => {
const now = 1800000000000;
const request = install({
now: () => now,
fetchImpl: async () =>
Response.json({ ...snapshot, now: (now - 120000) / 1000 }),
});
const res = await request();
assert.equal(res.body.ageMs, 120000);
assert.equal(res.body.stale, true);
for (const sourceNow of [(now - 1800001) / 1000, undefined, NaN]) {
const missing = install({
now: () => now,
fetchImpl: async () => Response.json({ ...snapshot, now: sourceNow }),
});
assert.equal((await missing()).status, 502);
}
});
test('GERMAN: cache age and cooldown remain stable across a wall-clock rollback', async () => {
let wall = 1800000000000,
mono = 0,
calls = 0;
const request = install({
now: () => wall,
monotonicNow: () => mono,
fetchImpl: async () => {
calls++;
return calls === 1
? Response.json({ ...snapshot, now: wall / 1000 })
: new Response('', { status: 429, headers: { 'Retry-After': '60' } });
},
});
await request();
wall -= 3600000;
mono = 61000;
const stale = await request();
assert.equal(stale.body.ageMs, 61000);
assert.equal(stale.body.stale, true);
mono += 1000;
assert.equal((await request('/?lat=1&lon=1')).headers['Retry-After'], '59');
assert.equal(calls, 2);
});
test('GERMAN: cache hits and coalesced reads do not consume upstream admission', async () => {
let calls = 0;
const request = install({
now: () => 1800000000000,
fetchImpl: async () => {
calls++;
return Response.json(snapshot);
},
});
for (let i = 0; i < 100; i++) assert.equal((await request()).status, 200);
assert.equal((await request('/?lat=1&lon=1')).status, 200);
assert.equal(calls, 2);
});
test('GERMAN: distinct concurrent upstream reads are bounded', async () => {
const pending = [];
const request = install({
fetchImpl: () => new Promise((resolve) => pending.push(resolve)),
});
const jobs = Array.from({ length: 30 }, (_, i) =>
request(`/?lat=${i}&lon=1`),
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(pending.length <= 8);
pending.forEach((resolve) => resolve(Response.json(snapshot)));
const results = await Promise.all(jobs);
assert.ok(results.some((result) => result.status === 429));
});
+5
View File
@@ -607,6 +607,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'flights',
}),
Object.freeze({
id: 'gnss-integrity',
token: '3',
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }),
Object.freeze({
id: 'local-datacenters',
+30 -13
View File
@@ -202,8 +202,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 29);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
assert.equal(REGISTERED_LAYER_IDS.length, 30);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 30);
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -242,19 +242,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
}
assert.equal(nextLayerStateToken(), '3');
assert.equal(nextLayerStateToken(), '4');
assert.equal(
nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
alpha: '4',
bravo: '5',
}),
'5',
'6',
);
const digitsExhausted = {
...LAYER_STATE_TOKEN_RESERVATIONS,
...Object.fromEntries(
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
[...'456789'].map((digit) => [`prior-${digit}`, digit]),
),
};
assert.equal(nextLayerStateToken(digitsExhausted), '00');
@@ -307,8 +307,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '3',
next: '4',
future: '4',
next: '5',
}),
true,
);
@@ -318,7 +318,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00',
}),
/next free token 3/,
/next free token 4/,
);
const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9'];
@@ -341,10 +341,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.throws(
() =>
validateLayerStateAllocations(
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4' },
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '4', competing: '4' },
),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
@@ -2639,6 +2639,23 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
);
});
test('GNSS interference uses digit 3 without colliding with other digit tokens', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=3.2.1'));
assert.deepEqual(decoded.enabledLayerIds, [
'fire-perimeters',
'gnss-integrity',
'recent-imagery',
]);
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'gnss-integrity').token,
'3',
);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encode(decoded))),
decoded,
);
});
test('fire perimeters uses digit 2 without colliding with wind or recent imagery', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=2.k.1'));
assert.deepEqual(decoded.enabledLayerIds, [
+33 -29
View File
@@ -128,10 +128,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 30);
assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
{
...LEGACY_LAYER_STATE_TOKENS,
'street-level': '0',
'gnss-integrity': '3',
},
);
assert.throws(
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
@@ -151,9 +155,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/,
);
assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
parseLayerStateTokenReservations([...reservationRows, ['future', '4']])
.future,
'3',
'4',
);
assert.throws(
() =>
@@ -171,14 +175,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '3']] },
{ rows: [...reservationRows, ['retired-layer', '4']] },
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
assert.equal(nextLayerStateToken(published), '5');
assert.equal(
validateLayerStateAllocations(published, {
...published,
newcomer: '4',
newcomer: '5',
}),
true,
);
@@ -186,15 +190,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
() =>
validateLayerStateAllocations(published, {
...published,
competing: '3',
competing: '4',
}),
/next free token 4/,
/next free token 5/,
);
assert.throws(
() =>
validateLayerStateAllocations(published, {
...LAYER_STATE_TOKEN_RESERVATIONS,
newcomer: '3',
newcomer: '4',
}),
/changed or removed: retired-layer/,
);
@@ -202,8 +206,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
);
});
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
test('PR B manually replaces provisional 4 with 5 after PR A publishes 4', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '4']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join(
cwd,
@@ -224,8 +228,8 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-b', '3']],
[{ id: 'pr-b', token: '3' }],
[...reservationRows, ['pr-b', '4']],
[{ id: 'pr-b', token: '4' }],
);
const beforeRebase = runCheck();
assert.equal(beforeRebase.status, 1);
@@ -233,10 +237,10 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
[...reservationRows, ['pr-a', '4'], ['pr-b', '4']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '3' },
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '4' },
],
);
const staleRebase = runCheck();
@@ -244,16 +248,16 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '4');
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
assert.equal(nextLayerStateToken(published), '5');
const validBaselineRows = [...reservationRows, ['pr-a', '4']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '4' }]);
const helper = spawnSync(
process.execPath,
[realpathSync(fixtureNext), 'pr-b'],
{ cwd, encoding: 'utf8' },
);
assert.equal(helper.status, 0, helper.stderr);
assert.match(helper.stdout, /pr-b: 4/);
assert.match(helper.stdout, /pr-b: 5/);
assert.deepEqual(
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
validBaselineRows,
@@ -261,20 +265,20 @@ test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () =>
);
writeCodecFixture(
cwd,
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
[...reservationRows, ['pr-a', '4'], ['pr-b', '5']],
[
{ id: 'pr-a', token: '3' },
{ id: 'pr-b', token: '4' },
{ id: 'pr-a', token: '4' },
{ id: 'pr-b', token: '5' },
],
);
const corrected = runCheck();
assert.equal(corrected.status, 0, corrected.stderr);
assert.match(corrected.stdout, /30 published, 1 new/);
assert.match(corrected.stdout, /31 published, 1 new/);
});
});
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
const digitRows = [...'456789'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([
...reservationRows,
...digitRows,
@@ -326,7 +330,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
try {
const priorDigits = [...'3456789'].map((digit) => [
const priorDigits = [...'456789'].map((digit) => [
`qa-prior-digit-${digit}`,
digit,
]);
@@ -363,7 +367,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
const params = new URLSearchParams([['v', '2']]);
codec.encodeLayerStateParams(params, state);
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
assert.equal(params.get('l').length, 69);
assert.equal(params.get('l').length, 71);
const restored = codec.decodeLayerStateParams(params);
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
assert.deepEqual(restored?.options, state.options);
@@ -381,7 +385,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
},
(cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 30);
assert.equal(Object.keys(published).length, 31);
assert.equal(published['retired-layer'], '00');
assert.throws(
() =>
@@ -9,6 +9,7 @@
["earthquakes", "e"],
["fire-perimeters", "2"],
["flights", "f"],
["gnss-integrity", "3"],
["local-dams", "q"],
["local-datacenters", "d"],
["local-firms", "w"],
+10 -4
View File
@@ -2398,10 +2398,13 @@ test('a newer absolute intent aborts a hung guard-compensation instead of starvi
removeGuard();
// The newest intent must be able to abort the hung compensation and run.
const pendingFinalOn = mgr.setEnabled('flights', true, { origin: 'user' });
let giveUpTimer;
const finalOn = await Promise.race([
pendingFinalOn,
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
assert.notEqual(finalOn, 'starved', 'newest intent must not starve behind the compensation');
assert.equal(compensationAborted, true, 'the hung compensation was aborted');
assert.equal(await pendingBlockedOn, false, 'the guard-blocked request stays unfulfilled');
@@ -2451,10 +2454,13 @@ test('re-entrant setEnabled from a blocked-adoption listener supersedes the comp
releaseFirstDisable();
const blockedOn = await pendingBlockedOn;
let giveUpTimer;
const reentrant = await Promise.race([
(async () => reentrantResult === null ? 'never-fired' : await reentrantResult)(),
new Promise((resolve) => setTimeout(() => resolve('starved'), 2000)),
]);
new Promise((resolve) => {
giveUpTimer = setTimeout(() => resolve('starved'), 2000);
}),
]).finally(() => clearTimeout(giveUpTimer));
await pendingOff;
await mgr.waitForLayerSettled('flights');
unsubscribe();
+6
View File
@@ -71,6 +71,12 @@ test('military analyst record: full record maps every contract field', () => {
aircraftClass: 'widebody',
originCountry: null,
operator: 'United States Air Force',
squawk: null,
emergency: false,
emergencyKind: null,
emergencySource: null,
emergencyBroadcast: null,
lastContactAtMs: null,
routeOrigin: null,
routeDestination: null,
});
+64
View File
@@ -1,5 +1,9 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import https from 'node:https';
import { Readable } from 'node:stream';
import test from 'node:test';
import { fetchPinnedRadioResponse } from '../../server/providers/radio/transport.js';
import {
createRadioProxyMiddleware,
isPublicRadioAddress,
@@ -469,3 +473,63 @@ test('proxy rejects any hostname resolution containing a forbidden address befor
}
assert.equal(fetchCount, 0);
});
// Answer every pinned request with `status`, invoking the response callback
// asynchronously as `https.request` does, outside the caller's promise.
// The returned promise rejects on an uncaught exception, so a regression fails
// the race at once instead of leaving the fetch pending.
function mockMirrorStatus(t, status, statusMessage = '') {
let crash;
const crashed = new Promise((_resolve, reject) => (crash = reject));
const onUncaught = (error) =>
crash(new Error(`uncaught exception: ${error.message}`));
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
t.mock.method(https, 'request', (_url, _options, callback) => {
const request = new EventEmitter();
request.end = () =>
setImmediate(() =>
callback(
Object.assign(Readable.from([]), {
statusCode: status,
statusMessage,
headers: { 'content-type': 'application/json' },
}),
),
);
return request;
});
return crashed;
}
function fetchMirror() {
return fetchPinnedRadioResponse(
new URL('https://de1.api.radio-browser.info/json/servers'),
{ headers: {} },
[{ address: '93.184.216.34', family: 4 }],
);
}
for (const status of [204, 205, 304]) {
test(`a mirror answering null-body status ${status} resolves with a null body instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
const response = await Promise.race([fetchMirror(), crashed]);
assert.equal(response.status, status);
assert.equal(response.body, null);
});
}
for (const status of [600, 999]) {
test(`a mirror answering out-of-range status ${status} rejects instead of crashing`, async (t) => {
const crashed = mockMirrorStatus(t, status);
await assert.rejects(
Promise.race([fetchMirror(), crashed]),
new RegExp(`unsupported status ${status}`),
);
});
}
test('a mirror answering an invalid reason phrase rejects instead of crashing', async (t) => {
const crashed = mockMirrorStatus(t, 200, 'OK\x7f');
await assert.rejects(Promise.race([fetchMirror(), crashed]), TypeError);
});
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRegionalArticles } from './regionalModel.js';
test('compact source dates must denote an actual calendar instant', () => {
for (const date of [
'20260230T120000Z',
'20260431T120000Z',
'20261301T120000Z',
'20260716T240000Z',
'20260716T126000Z',
'20260716T120060Z',
]) {
const [article] = normalizeRegionalArticles({
articles: [
{
title: 'Fixture',
url: 'https://fixture.example/news',
seendate: date,
},
],
});
assert.equal(article.publishedAt, null, date);
assert.equal(article.title, 'Fixture');
}
for (const [input, expected] of [
['20240229T004500Z', '2024-02-29T00:45:00Z'],
['20260716T000000Z', '2026-07-16T00:00:00Z'],
['2026-07-16T12:00:00Z', '2026-07-16T12:00:00.000Z'],
]) {
assert.equal(
normalizeRegionalArticles({
articles: [
{
title: 'Fixture',
url: 'https://fixture.example/news',
seendate: input,
},
],
})[0].publishedAt,
expected,
);
}
});
+28 -11
View File
@@ -67,11 +67,20 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
seen.add(signature);
const rawDate = cleanText(row?.seendate, 32);
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
const candidateDate = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate))
? null
: new Date(rawDate).toISOString();
: rawDate;
const parsedMs = Date.parse(candidateDate);
const validDate =
Number.isFinite(parsedMs) &&
(!compactDate ||
new Date(parsedMs).toISOString().slice(0, 19) ===
candidateDate.slice(0, 19));
const publishedAt = validDate
? compactDate
? candidateDate
: new Date(parsedMs).toISOString()
: null;
articles.push({
title,
url,
@@ -87,14 +96,22 @@ export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
return articles;
}
/** A reported number, or null when the observation is missing or not numeric. */
function observedNumber(value) {
if (
value == null ||
typeof value === 'boolean' ||
(typeof value === 'string' && !value.trim())
)
return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
if (!current || observedNumber(current.temperature_2m) == null) return null;
const numberOrNull = observedNumber;
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw =
@@ -119,8 +136,8 @@ export function normalizeRegionalWeather(payload) {
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
const value = observedNumber(code);
if (value == null) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeOsrmSteps, ROUTE_STEPS_MAX } from './routeSteps.js';
const step = (type) => ({
maneuver: { type, location: [10, 20] },
distance: 10,
duration: 1,
});
test('exact-cap routes remain complete and boundary exits retain their distance', () => {
const steps = Array.from({ length: ROUTE_STEPS_MAX }, () => step('turn'));
assert.equal(normalizeOsrmSteps({ legs: [{ steps }] }).truncated, false);
assert.equal(
normalizeOsrmSteps({ legs: [{ steps: [...steps, step('turn')] }] })
.truncated,
true,
);
steps[steps.length - 1] = step('roundabout');
const result = normalizeOsrmSteps({
legs: [{ steps: [...steps, step('exit roundabout')] }],
});
assert.equal(result.truncated, false);
assert.equal(result.steps.length, ROUTE_STEPS_MAX);
assert.equal(result.steps.at(-1).distanceM, 20);
});
+1 -1
View File
@@ -185,9 +185,9 @@ export function normalizeOsrmSteps(route) {
}
continue;
}
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
step.instruction = instructionFor(step);
out.push(step);
if (out.length >= ROUTE_STEPS_MAX) return { steps: out, truncated: true };
}
}
return { steps: out, truncated: false };
+2 -2
View File
@@ -206,9 +206,9 @@ test('the step list is capped, and the cap is reported rather than hidden', () =
// would present a route that simply stops in the middle of a road.
assert.equal(cut.truncated, true);
const exact = normalizeOsrmSteps({
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX - 1) }],
legs: [{ steps: many.slice(0, ROUTE_STEPS_MAX) }],
});
assert.equal(exact.steps.length, ROUTE_STEPS_MAX - 1);
assert.equal(exact.steps.length, ROUTE_STEPS_MAX);
assert.equal(exact.truncated, false, 'a route that fits is not cut');
});
+7 -1
View File
@@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) {
if (parts.length !== 2) return null;
const lon = Number(parts[0]);
const lat = Number(parts[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
if (
!Number.isFinite(lon) ||
!Number.isFinite(lat) ||
Math.abs(lon) > 180 ||
Math.abs(lat) > 90
)
return null;
points.push([lon, lat]);
}
return points;
+11
View File
@@ -3,6 +3,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
parseTerrainPoints,
fetchTerrainChunkWithRetry,
resolveTerrainHeightRequest,
terrainPointKey,
@@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as
assert.equal(response.cacheChanged, false);
}
});
test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => {
for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91'])
assert.equal(parseTerrainPoints(value), null, value);
assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [
[-180, -90],
[180, 90],
[0, 0],
]);
});
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeRegionalWeather, weatherCodeLabel } from './regionalModel.js';
test('missing weather observations never assert clear conditions', () => {
for (const value of [null, undefined, '', ' ', false, true]) {
assert.equal(weatherCodeLabel(value), 'CONDITIONS UNKNOWN');
assert.equal(
normalizeRegionalWeather({ current: { temperature_2m: value } }),
null,
);
}
assert.equal(weatherCodeLabel(0), 'CLEAR');
assert.equal(weatherCodeLabel('0'), 'CLEAR');
const record = normalizeRegionalWeather({
current: { temperature_2m: 0, weather_code: null, wind_speed_10m: false },
});
assert.equal(record.temperatureC, 0);
assert.equal(record.weatherCode, null);
assert.equal(record.windKph, null);
});
+6 -1
View File
@@ -28,6 +28,11 @@ export function resolveCameraMove(scene, shot) {
};
}
/** Wrap an interpolated camera longitude to WGS84 without changing authored endpoints. */
export function normalizeCameraLongitude(longitude) {
return ((((longitude + 180) % 360) + 360) % 360) - 180;
}
/** Sample the same authored curve for playback and seeking, taking the short longitude/angle arc. */
export function sampleCameraMove(move, progress) {
const t = Math.max(0, Math.min(1, Number(progress) || 0));
@@ -45,7 +50,7 @@ export function sampleCameraMove(move, progress) {
const { from, to } = move;
return {
lat: lerp(from.lat, to.lat),
lon: ((((angle(from.lon, to.lon) + 180) % 360) + 360) % 360) - 180,
lon: normalizeCameraLongitude(angle(from.lon, to.lon)),
alt: lerp(from.alt, to.alt),
heading: angle(from.heading, to.heading),
pitch: lerp(from.pitch, to.pitch),
@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { cameraAtProgress } from './timeline.js';
const camera = (lon) => ({
lat: 10,
lon,
alt: 1000,
heading: 0,
pitch: -40,
roll: 0,
});
test('director seek follows the short dateline arc in both directions', () => {
for (const [a, b] of [
[179, -179],
[-179, 179],
]) {
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.ok(Math.abs(cameraAtProgress(camera(a), camera(b), 0.25).lon) > 179);
}
assert.equal(cameraAtProgress(camera(20), camera(24), 0.5).lon, 22);
});
test('director seek keeps exact ±180 endpoints and picks the short arc near the antipode', () => {
for (const [a, b] of [
[180, -180],
[-180, 180],
]) {
assert.equal(cameraAtProgress(camera(a), camera(b), 0).lon, a);
assert.equal(cameraAtProgress(camera(a), camera(b), 1).lon, b);
assert.equal(
Math.abs(cameraAtProgress(camera(a), camera(b), 0.5).lon),
180,
);
}
// 10°E to 169.9°W is 179.9° westward through Greenwich, not 180.1° east.
const mid = cameraAtProgress(camera(10), camera(-169.9), 0.5).lon;
assert.ok(mid > -81 && mid < -79, `short arc midpoint, got ${mid}`);
});
test('director seek turns heading and roll the short way for any authored angles', () => {
const pose = (heading, roll) => ({ ...camera(0), heading, roll });
const mid = cameraAtProgress(pose(350, 350), pose(-350, -350), 0.5);
// 350° and -350° are 20° apart through 0°/360°, not 340° through 180°.
for (const value of [mid.heading, mid.roll]) {
const wrapped = ((value % 360) + 360) % 360;
assert.ok(wrapped < 1e-9 || 360 - wrapped < 1e-9, `got ${value}`);
}
});
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createAssetDirectorySource } from './source.js';
test('cancellation during the terminating read cannot return a scene asset', async () => {
for (const cancel of [true, false]) {
const controller = new AbortController();
let reads = 0,
cancelled = 0,
released = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://fixture.example/assets/',
fetchImpl: async () => ({
ok: true,
headers: new Headers({ 'content-type': 'application/octet-stream' }),
body: {
getReader() {
return {
async read() {
reads++;
if (reads === 1)
return { done: false, value: new Uint8Array([1, 2]) };
if (cancel) controller.abort();
return { done: true };
},
async cancel() {
cancelled++;
},
releaseLock() {
released++;
},
};
},
},
}),
});
const pending = source({ path: 'scene.bin', signal: controller.signal });
if (cancel) await assert.rejects(pending, { name: 'AbortError' });
else assert.deepEqual([...(await pending).bytes], [1, 2]);
assert.equal(cancelled, 1);
assert.equal(released, 1);
}
});
test('cancellation during reader cleanup cannot return a scene asset', async () => {
const controller = new AbortController();
const reason = new DOMException('scene changed', 'AbortError');
let released = 0;
const source = createAssetDirectorySource({
baseUrl: 'https://fixture.example/assets/',
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
body: {
getReader() {
let reads = 0;
return {
async read() {
reads++;
return reads === 1
? { done: false, value: new Uint8Array([1]) }
: { done: true };
},
async cancel() {
controller.abort(reason);
},
releaseLock() {
released++;
},
};
},
},
}),
});
await assert.rejects(
source({ path: 'scene.bin', signal: controller.signal }),
(error) => error === reason,
);
assert.equal(released, 1);
});
+3
View File
@@ -41,6 +41,7 @@ export function createAssetDirectorySource({
for (;;) {
signal?.throwIfAborted();
const { done, value } = await reader.read();
signal?.throwIfAborted();
if (done) break;
length += value.byteLength;
if (length > maxBytes) throw new Error('Asset exceeds byte limit');
@@ -50,6 +51,8 @@ export function createAssetDirectorySource({
await reader.cancel().catch(() => {});
reader.releaseLock();
}
// Cleanup awaits too; a cancel during it must still discard the asset.
signal?.throwIfAborted();
const bytes = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
+10 -2
View File
@@ -2,6 +2,7 @@ import {
resolveCameraPose,
resolveCameraMove,
sampleCameraMove,
normalizeCameraLongitude,
} from './camera.js';
const DEFAULT_SHOT_DURATION_SEC = 4;
@@ -40,12 +41,19 @@ export function cameraAtProgress(fromCamera, toCamera, progress) {
const lerp = (from, to) => Number(from) + (Number(to) - Number(from)) * eased;
const lerpAngle = (from, to) => {
const start = Number(from) || 0;
const delta = ((Number(to) - start + 540) % 360) - 180;
// Double modulo keeps the delta in [-180, 180) for any authored angles,
// as sampleCameraMove does; a single % goes negative below -540.
const delta = ((((Number(to) - start + 540) % 360) + 360) % 360) - 180;
return start + delta * eased;
};
return {
lat: lerp(source.lat, target.lat),
lon: lerp(source.lon, target.lon),
lon:
t === 0
? Number(source.lon)
: t === 1
? Number(target.lon)
: normalizeCameraLongitude(lerpAngle(source.lon, target.lon)),
alt: lerp(source.alt, target.alt),
heading: lerpAngle(source.heading, target.heading),
pitch: lerp(source.pitch, target.pitch),
+3 -3
View File
@@ -673,11 +673,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// referent-only track_entity alternative (kept out of the model-facing
// schema, which may not carry top-level anyOf), and Contacts requested-radius
// list descriptions; the missions still ride existing tools.
// Street Level adds toggle enum values and the generated alias hint.
assert.equal(block.length, 29802, 'serialized tool schema length drifted');
// Street Level and GNSS extend the manifest; broadcast caveats describe tracked and analyst aircraft.
assert.equal(block.length, 30511, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'b316ca2e67848eb565b6847ac044a19d1465d8ef8252a5dfbd7d947a2a36d5ed',
'62d5df9cedf413403e4fd0a7cbe94bb2cd13736c3d8fedc84bcccd63ad253eb6',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
googlePlacesContextProxy,
validateGeocodeAddress,
geocodeBounds,
} from '../vite.config.js';
const UPSTREAM = 'https://geocode.test/json';
function installRoutes({ apiKey = 'server-secret', fetchImpl } = {}) {
const routes = new Map();
googlePlacesContextProxy({
resolveApiKey: () => apiKey,
fetchImpl,
endpoints: { geocode: UPSTREAM },
}).configureServer({
middlewares: { use: (name, handler) => routes.set(name, handler) },
});
return routes;
}
function invokeRoute(handler, { method = 'GET', url = '/' } = {}) {
return new Promise((resolve, reject) => {
const headers = new Map();
const res = {
statusCode: 200,
setHeader(name, value) {
headers.set(String(name).toLowerCase(), String(value));
},
end(body = '') {
resolve({
statusCode: this.statusCode,
headers: Object.fromEntries(headers),
body: body ? JSON.parse(String(body)) : null,
});
},
};
Promise.resolve(
handler(
{ method, url, headers: {}, socket: { remoteAddress: '127.0.0.1' } },
res,
),
).catch(reject);
});
}
test('geocoder query and viewport bias are validated before Google is paid', () => {
assert.deepEqual(validateGeocodeAddress(new URLSearchParams()), {
ok: false,
error: 'address is required',
});
assert.deepEqual(validateGeocodeAddress(new URLSearchParams({ address: ' ' })), {
ok: false,
error: 'address is required',
});
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: 'x'.repeat(201) })),
{ ok: false, error: 'address is too long' },
);
assert.deepEqual(
validateGeocodeAddress(new URLSearchParams({ address: ' Austin ' })),
{ ok: true, address: 'Austin' },
);
// A bias only ranks results, so an unusable one is dropped, not refused.
const bias = '30.1,-97.9|30.4,-97.6';
assert.equal(geocodeBounds(new URLSearchParams({ bounds: bias })), bias);
for (const bounds of [
'',
'30.1,-97.9',
'30.1,-97.9|30.4',
'north,-97.9|30.4,-97.6',
'91,-97.9|30.4,-97.6',
'30.1,-181|30.4,-97.6',
// A blank component must not read as 0 — that is the equator, not a corner.
'30.1,-97.9|30.4,',
'30.1,|30.4,-97.6',
'30.1,-97.9| ,-97.6',
])
assert.equal(geocodeBounds(new URLSearchParams({ bounds })), null);
});
test('the geocode routes keep the key server-side and answer in Google shape', async () => {
const upstream = [];
const routes = installRoutes({
fetchImpl: async (url) => {
upstream.push(new URL(String(url)));
return Response.json({ status: 'OK', results: [{ place_id: 'fixture' }] });
},
});
const forward = routes.get('/api/google/geocode');
const reverse = routes.get('/api/google/reverse-geocode');
assert.equal(typeof forward, 'function');
assert.equal(typeof reverse, 'function');
const forwarded = await invokeRoute(forward, {
url: '/?address=Austin&bounds=30.1,-97.9|30.4,-97.6',
});
assert.equal(forwarded.statusCode, 200);
assert.deepEqual(forwarded.body, {
status: 'OK',
results: [{ place_id: 'fixture' }],
});
assert.equal(upstream.at(-1).searchParams.get('address'), 'Austin');
assert.equal(upstream.at(-1).searchParams.get('bounds'), '30.1,-97.9|30.4,-97.6');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
const reversed = await invokeRoute(reverse, { url: '/?lat=30.27&lon=-97.74' });
assert.equal(reversed.statusCode, 200);
assert.equal(upstream.at(-1).searchParams.get('latlng'), '30.27,-97.74');
assert.equal(upstream.at(-1).searchParams.get('key'), 'server-secret');
// The key travels to Google, never back to the page that asked.
assert.ok(!JSON.stringify(forwarded.body).includes('server-secret'));
assert.ok(!JSON.stringify(reversed.body).includes('server-secret'));
// A malformed bias is dropped rather than forwarded.
await invokeRoute(forward, { url: '/?address=Austin&bounds=north' });
assert.equal(upstream.at(-1).searchParams.has('bounds'), false);
});
test('a bad or keyless geocode request never reaches Google', async () => {
const upstream = [];
const fetchImpl = async (url) => {
upstream.push(String(url));
return Response.json({ status: 'OK', results: [] });
};
const routes = installRoutes({ fetchImpl });
for (const [path, url] of [
['/api/google/geocode', '/?address='],
['/api/google/reverse-geocode', '/?lat=91&lon=-97.74'],
]) {
const response = await invokeRoute(routes.get(path), { url });
assert.equal(response.statusCode, 400);
assert.deepEqual(response.body.results, []);
assert.equal(response.body.status, 'REQUEST_DENIED');
}
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const rejected = await invokeRoute(routes.get(path), {
method: 'POST',
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(rejected.statusCode, 405);
}
// Keyless is an empty capability, not an outage: a 200 the browser adapters
// read as "this provider did not answer", so the chain moves on. It resolves
// ahead of validation, like the Places routes: with nothing configured there
// is no request to validate and no quota to protect.
const keyless = installRoutes({ apiKey: ' ', fetchImpl });
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode'])
for (const url of ['/?address=Austin&lat=30&lon=-97', '/?address=&lat=91']) {
const response = await invokeRoute(keyless.get(path), { url });
assert.equal(response.statusCode, 200);
assert.equal(response.headers['cache-control'], 'no-store');
assert.deepEqual(response.body, {
configured: false,
status: 'REQUEST_DENIED',
results: [],
error: null,
});
}
assert.deepEqual(upstream, []);
});
test('an upstream failure reaches the browser as fixed public text', async () => {
const routes = installRoutes({
fetchImpl: async () => {
throw new Error('connect ECONNREFUSED 10.0.0.7:443');
},
});
for (const path of ['/api/google/geocode', '/api/google/reverse-geocode']) {
const response = await invokeRoute(routes.get(path), {
url: '/?address=Austin&lat=30&lon=-97',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
}
});
test('an oversized upstream body is dropped rather than buffered', async () => {
const routes = installRoutes({
// Two hundred times the 256 KiB ceiling, declared honestly.
fetchImpl: async () =>
Response.json({ status: 'OK', results: [{ pad: 'x'.repeat(52_428_800) }] }),
});
const response = await invokeRoute(routes.get('/api/google/geocode'), {
url: '/?address=Austin',
});
assert.equal(response.statusCode, 502);
assert.deepEqual(response.body, {
status: 'UNKNOWN_ERROR',
results: [],
error: 'Google Geocoding request failed',
});
});
+12 -4
View File
@@ -67,11 +67,19 @@ test('the Street View tool resolves per-variable overrides before preferring the
}
});
test('both Places routes select the intended key and keep it out of responses', async (t) => {
test('every Google route selects the intended key and keeps it out of responses', async (t) => {
const original = { server: process.env.GOOGLE_MAPS_SERVER_API_KEY, browser: process.env.GOOGLE_MAPS_API_KEY, limit: process.env.GEV_RATELIMIT_GOOGLE_PER_MIN };
const calls = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
calls.push({ url, key: options.headers['X-Goog-Api-Key'] });
// Places sends the key as a header; Geocoding's web service takes it as a
// query parameter. Either way it must be the server key, and never the
// browser's copy of it.
calls.push({
url,
key:
options?.headers?.['X-Goog-Api-Key'] ??
new URL(String(url)).searchParams.get('key'),
});
return Response.json({ places: [] });
});
try {
@@ -88,12 +96,12 @@ test('both Places routes select the intended key and keep it out of responses',
for (const install of ['configureServer', 'configurePreviewServer']) {
const routes = new Map();
googlePlacesContextProxy()[install]({ middlewares: { use: (name, handler) => routes.set(name, handler) } });
assert.equal(routes.size, 2);
assert.equal(routes.size, 4);
for (const handler of routes.values()) {
const before = calls.length;
let body;
const res = { setHeader() {}, end(value) { body = value; } };
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
await handler({ method: 'GET', url: '/?lat=30&lon=-97&q=capitol&address=capitol', headers: {}, socket: { remoteAddress: '127.0.0.1' } }, res);
assert.equal(res.statusCode, 200);
assert.ok(!body.includes('server-secret'));
if (expected) {
+27 -1
View File
@@ -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}`
+38 -1
View File
@@ -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();
+6
View File
@@ -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
+23 -1
View File
@@ -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,
};
+17
View File
@@ -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
+10
View File
@@ -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);
});
+29 -10
View File
@@ -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,
+422
View File
@@ -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;
}
+700
View File
@@ -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);
});
+318
View File
@@ -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',
});
+491
View File
@@ -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,
);
});
+76
View File
@@ -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,
};
},
};
}
+111
View File
@@ -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;
},
);
}
});
+6
View File
@@ -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
+22 -1
View File
@@ -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,
};
+4
View File
@@ -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,
+16
View File
@@ -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);
});
+19 -4
View File
@@ -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,
+10 -7
View File
@@ -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({
@@ -125,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
@@ -161,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
+6 -3
View File
@@ -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
+18 -7
View File
@@ -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 }));
});
+19 -6
View File
@@ -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),
});
}
+5 -1
View File
@@ -1,7 +1,11 @@
import { searchHls } from './catalog.js';
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
/**
* 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,
@@ -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();
}
});
+2 -2
View File
@@ -100,12 +100,12 @@ export function createThumbnailLoader({
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 {
+35 -1
View File
@@ -108,7 +108,15 @@ export function createStreetLevelLayer({
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/** `{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.
@@ -334,15 +342,36 @@ export function createStreetLevelLayer({
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;
@@ -407,6 +436,9 @@ export function createStreetLevelLayer({
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)],
};
@@ -606,9 +638,11 @@ export function createStreetLevelLayer({
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;
+127 -2
View File
@@ -4,6 +4,7 @@ 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';
/**
@@ -92,8 +93,8 @@ function deferred() {
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider() {
const provider = fakeProvider();
function posingProvider(options) {
const provider = fakeProvider(options);
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
@@ -818,3 +819,127 @@ test('an empty provider composition enables and tears down without acquiring ima
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);
});
+936
View File
@@ -0,0 +1,936 @@
import * as Cesium from 'cesium';
import { passesImageryFilter } from './filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
whenIdle,
} from './view.js';
import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js';
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
import { tileBounds, tilesForBbox } from './tileMath.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
const REMESH_INTERVAL_MS = 1500;
/** Gap between redrawing one dirty tile and the next. */
const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/** Sequences per primitive: smaller batches build, and show, sooner. */
const SEQUENCE_PRIMITIVE_BATCH = 120;
/** Separates a multi-part sequence's part index from its id in pick ids. */
const PART_SEPARATOR = '~';
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
function partIds(sequence, prefix) {
return sequence.parts.map((_, index) =>
index
? `${prefix}${sequence.id}${PART_SEPARATOR}${index}`
: `${prefix}${sequence.id}`,
);
}
/** Sequence id for any part's pick id (`<prefix><id>[~<part>]`), else null. */
export function sequenceIdFromPick(pickId, prefix) {
if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix))
return null;
const rest = pickId.slice(prefix.length);
const cut = rest.indexOf(PART_SEPARATOR);
return cut === -1 ? rest : rest.slice(0, cut);
}
/**
* Whether any point of `box` ({west, south, east, north}) is within the mesh
* sampler's range of `centre`, the ground point under the camera.
*/
export function meshBoxInRange(box, centre) {
// Move the camera's longitude to the box's side of the date line first:
// clamping 179.999 into a box at -179.99 would pick its far edge.
const lon =
centre.lon +
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
const nearest = {
lon: Math.min(box.east, Math.max(box.west, lon)),
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
};
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* @typedef {object} LineCoverageTuning
* @property {number} maxTiles Line tiles drawn at once.
* @property {number} overviewMaxTiles Overview tiles drawn at once.
* @property {number} maxSequences Lines drawn at once, over every tile.
* @property {number} lineWidthPx
* @property {number} overviewPointPx
* @property {number} moveDebounceMs Camera rest before the tile set is recomputed.
* @property {number} viewRangeMinM Street-zoom coverage reaches at least this far…
* @property {number} viewRangePerHeight …or camera height times this.
* @property {number} viewNearM Ground always covered around a tilted camera.
*/
/**
* Camera-driven coverage from any XYZ line-tile source: overview points from
* orbit, sequence lines near the ground. In terrain surface mode a tile is
* drawn draped first, then swapped for lines cast to the bare earth (draped
* lines would land on roofs), so coverage never waits on terrain heights.
*
* @param {object} options
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags.
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>, overview: Array<object>}>} options.fetchTile
* A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview
* points `{lon, lat, capturedAt, isPano}`. A rejection may carry
* `keyRequired`, `keyRejected` or `retryAfterSec`.
* @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing.
* @param {{coverage: string, selected: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the lines.
* @param {string} options.renderKey Render-governor reason.
* @param {LineCoverageTuning} options.tuning
* @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages
* @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point.
*/
export function createLineCoverage({
state,
fetchTile,
zoomFor,
colors,
pickPrefix,
renderKey,
tuning,
messages,
passes = passesImageryFilter,
}) {
const { render } = state.services;
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
/** Colour for a line: the provider's, GEV cyan while selected. */
function sequenceColor({ selected = false } = {}) {
return selected
? Cesium.Color.fromCssColorString(colors.selected)
: Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92);
}
function requestRender() {
render?.governorRequestRender?.(renderKey);
}
function ensureTerrainReady() {
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`;
}
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.context.getFilter();
}
function terrainMode() {
return (
state.context.getSurface?.() === 'terrain' &&
Boolean(state.context.groundCaster)
);
}
/**
* A tile's sequences, filtered then capped: capping first could leave a
* dense tile of newer flat captures with no 360° lines at all.
*/
function drawnSequences(entry) {
const drawn = [];
const current = filter();
for (const sequence of entry.sequenceList) {
if (drawn.length >= perTileSequenceCap) break;
if (passes(sequence, current)) drawn.push(sequence);
}
return drawn;
}
/**
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
* records the selection its colours were built with.
*/
function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null;
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
const selectedId = state.sequence.selectedId ?? null;
const draped = [];
const cast = [];
for (const sequence of sequences) {
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor({ selected: sequence.id === selectedId }),
);
const ids = partIds(sequence, pickPrefix);
sequence.parts.forEach((coordinates, index) => {
const flat = ground?.castLine(coordinates, { meshAt });
let positions;
try {
positions = flat
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
} catch {
return;
}
if (positions.length < 2) return;
const geometry = flat
? new Cesium.PolylineGeometry({
positions,
width: tuning.lineWidthPx,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
})
: new Cesium.GroundPolylineGeometry({
positions,
width: tuning.lineWidthPx,
});
(flat ? cast : draped).push(
new Cesium.GeometryInstance({
geometry,
id: ids[index],
attributes: { color },
}),
);
});
}
const primitives = [];
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: true,
selectedId,
primitive: new Cesium.GroundPolylinePrimitive({
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
});
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: false,
selectedId,
primitive: new Cesium.Primitive({
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
allowPicking: true,
}),
});
return { primitives, draped, cast: cast.length };
}
/**
* Fetch the terrain heights a tile's lines need, then redraw it cast.
* `castRequested` stops the redraw from casting again at once; a refresh
* `retry`s a tile whose lines are still draped (a failed or partial cast),
* and it is redrawn only when more of its lines can be cast.
*/
function castTile(entry, { retry = false } = {}) {
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
return;
entry.castRequested = true;
entry.castAbort = new AbortController();
const { signal } = entry.castAbort;
const lines = [...entry.sequences.values()].flatMap(
(sequence) => sequence.parts,
);
const caster = state.context.groundCaster;
caster.prepareLines(lines, { signal }).then(() => {
if (entry.castAbort?.signal === signal) entry.castAbort = null;
const attached = [...state.coverage.tiles.values()].includes(entry);
if (signal.aborted || !attached || !terrainMode()) return;
// Nothing new to cast: keep what is drawn until the next refresh.
// Counted like buildSequencePrimitives: a cast line needs two points.
const castable = lines.filter(
(coords) => caster.castLine(coords)?.length >= 6,
).length;
if (castable <= (entry.castLines || 0)) {
entry.castRequested = (entry.castLines || 0) > 0;
return;
}
// A remesh may have cleared the flag while this was in flight.
entry.castRequested = true;
redrawTile(entry);
requestMesh(entry);
});
}
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
}
/** Lon/lat under the camera, where mesh range is measured from. */
function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* A tile's drawn lines with bounding boxes, built once per filter; mesh
* points are densified the first time a line is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
entry.meshParts = [];
for (const sequence of entry.sequences.values())
for (const part of sequence.parts) {
const box = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity,
};
for (const [lon, lat] of part) {
if (lon < box.west) box.west = lon;
if (lon > box.east) box.east = lon;
if (lat < box.south) box.south = lat;
if (lat > box.north) box.north = lat;
}
entry.meshParts.push({ part, box, points: null });
}
return entry.meshParts;
}
/**
* Ask for mesh samples under a cast tile's lines within the sampler's range
* of the camera; the rest are asked for when a camera move brings them in.
*/
function requestMesh(entry) {
const sampler = state.context.meshSampler;
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
const centre = meshCentre();
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
return;
const points = [];
for (const line of meshParts(entry)) {
if (!meshBoxInRange(line.box, centre)) continue;
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
for (const point of line.points) points.push(point);
}
if (points.length) sampler.request(points);
}
/** Redraw the tiles new mesh samples fall in, throttled. */
function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) {
if (entry.kind !== 'sequence' || !entry.bounds) continue;
const { west, south, east, north } = entry.bounds;
if (
batch.some(
([lon, lat]) =>
lon >= west && lon <= east && lat >= south && lat <= north,
)
)
state.coverage.remeshDirty.add(entry);
}
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
const wait = Math.max(
0,
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
);
state.coverage.remeshTimer = setTimeout(remesh, wait);
}
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
function remesh() {
state.coverage.remeshTimer = null;
state.coverage.remeshAt = Date.now();
if (!terrainMode() || !state.context.isActive()) {
state.coverage.remeshDirty.clear();
return;
}
const attached = new Set(state.coverage.tiles.values());
const [entry] = state.coverage.remeshDirty;
if (!entry) return;
state.coverage.remeshDirty.delete(entry);
if (attached.has(entry)) {
redrawTile(entry);
requestRender();
}
if (state.coverage.remeshDirty.size)
state.coverage.remeshTimer = setTimeout(
() => whenIdle(remesh, 500),
REMESH_STAGGER_MS,
);
}
state.coverage.remeshDirty = new Set();
state.context.meshSampler?.onSampled(onMeshSampled);
/**
* Remove a tile's previous primitives once all its current ones are ready
* (or after a few seconds); detaching the tile removes them at once.
*/
function removeWhenReady(entry, old) {
const scene = state.viewer?.scene;
finishSwap(entry);
if (!scene?.postRender) {
removePrimitives(old);
return;
}
const fresh = entry.primitives;
const started = Date.now();
const stop = scene.postRender.addEventListener(() => {
const ready = fresh.every(
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
);
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
requestRender();
return;
}
finishSwap(entry);
requestRender();
});
entry.swap = { old, stop };
requestRender();
}
function finishSwap(entry) {
if (!entry.swap) return;
entry.swap.stop();
removePrimitives(entry.swap.old);
entry.swap = null;
}
function buildOverviewCollection(points) {
const collection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha(
0.85,
);
const drawn = [];
const current = filter();
for (const point of points) {
if (!passes(point, current)) continue;
drawn.push(
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: tint,
pixelSize: tuning.overviewPointPx,
// Google 3D terrain and clouds must not hide the near side's dots;
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}),
);
}
return { collection, points: drawn };
}
/** Every overview point on the globe, current zoom and old alike. */
function* overviewPoints() {
for (const entry of drawnEntries())
if (entry.overviewPoints) yield* entry.overviewPoints;
}
// Overview points skip the depth test: hide the far hemisphere's.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: overviewPoints,
onChange: requestRender,
});
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
entry.primitives = primitives;
entry.count = sequences.length;
for (const { primitive, onGround } of primitives)
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
// Fewer lines cast than last time: the caster has dropped heights this
// tile used (a full cache), so it must be cast again.
if (cast < (entry.castLines || 0)) entry.castRequested = false;
entry.castLines = cast;
entry.drapedLines = draped.length;
if (draped.length) castTile(entry);
watchSelection();
} else {
const { collection, points } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.overviewPoints = points;
entry.count = points.length;
scene.primitives.add(collection);
// Cull the new points now, then all of them whenever the camera moves.
horizon.update(points);
}
}
function removePrimitives(list) {
const scene = state.viewer?.scene;
for (const { primitive, onGround } of list || []) {
try {
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
primitive,
);
} catch {
/* already gone */
}
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
finishSwap(entry);
removePrimitives(entry.primitives);
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
entry.overviewPoints = null;
}
}
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
function cancelCast(entry) {
entry.castAbort?.abort();
entry.castAbort = null;
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
cancelCast(entry);
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
async function loadTile(tile, kind) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
const controller = new AbortController();
state.coverage.pending.set(key, controller);
notify();
try {
// Fetch and the one-time terrain-height table load run side by side,
// both handled from the start so an early tile failure is not unhandled.
const [decoded] = await Promise.all([
fetchTile(tile, { signal: controller.signal }),
kind === 'sequence' ? ensureTerrainReady() : null,
]);
// Only an abort, a retire, a clear or a newer request for this tile
// discards the bytes; a refresh that still wants the tile keeps them.
const current = () =>
state.coverage.pending.get(key) === controller &&
!state.coverage.tiles.has(key);
if (
controller.signal.aborted ||
!current() ||
kind !== state.coverage.kind ||
tile.z !== state.coverage.zoom
)
return;
const entry = { kind, primitive: null, primitives: [], count: 0 };
// Padded: vector tiles carry a small buffer past their edge.
const bounds = tileBounds(tile.x, tile.y, tile.z);
const pad = (bounds.east - bounds.west) * 0.05;
entry.bounds = {
west: bounds.west - pad,
east: bounds.east + pad,
south: bounds.south - pad,
north: bounds.north + pad,
};
if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map();
entry.total = decoded.sequences.length;
} else {
entry.points = decoded.overview;
entry.sequences = new Map();
entry.total = decoded.overview.length;
}
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
requestRender();
} catch (error) {
if (
controller.signal.aborted ||
state.coverage.pending.get(key) !== controller
)
return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) {
// Every other tile would be refused too: stop asking.
state.keyRejected = true;
state.coverage.lastError =
messages.keyRejected || state.coverage.lastError;
}
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
} finally {
// Only the request that still owns the key settles it: a superseded one
// must not drop a newer request's entry (which is what counts as loading).
if (state.coverage.pending.get(key) === controller) {
state.coverage.pending.delete(key);
if (!state.coverage.pending.size) purgeStale();
}
notify();
}
}
/**
* The source is rate-limiting: keep what is drawn, request nothing until the
* wait is over, then refresh once.
*/
function holdFor(seconds) {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdUntil = Date.now() + seconds * 1000;
state.coverage.lastError = messages.rateLimited;
state.coverage.holdTimer = setTimeout(() => {
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
if (state.coverage.lastError === messages.rateLimited)
state.coverage.lastError = null;
refresh();
}, seconds * 1000);
}
/** Forget refusals when the provider goes off, so the next run asks again. */
function resetErrors() {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
state.coverage.lastError = null;
state.keyRejected = false;
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
function purgeStale() {
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = null;
if (!state.coverage.stale.size) return;
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
state.coverage.stale.clear();
requestRender();
}
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
function retire() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
}
state.coverage.tiles.clear();
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
}
function notify() {
state.context.notify();
}
/**
* Whether the screen centre meets the ground further ahead than the camera
* is high (a view tilted above 45°), or misses it.
*/
function isTilted({ nadir, ahead }, height) {
if (!ahead) return true;
return metresBetween(nadir, ahead) > Math.max(1, height);
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !state.context.isActive() || state.keyRequired) return;
if (state.statusKnown === false) return;
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
const ground = groundUnderCamera(viewer, {
groundAt: state.context.groundCaster?.groundAt,
});
const cameraHeight = viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const level = zoomFor(height);
const zoom = level?.zoom ?? null;
const kind = level?.kind ?? null;
// At street zooms the rays meet the ground where it really is (1,600 m up
// in Denver; Google 3D hides the globe) and stop short of the horizon.
const ranged =
kind === 'sequence'
? {
groundHeight: ground,
maxRange: Math.max(
tuning.viewRangeMinM,
height * tuning.viewRangePerHeight,
),
}
: {};
// Tilted street views rank tiles along the line of sight, from the ground
// under the camera to the ground at the centre of the screen.
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
let bbox = visibleBbox(viewer, {
...ranged,
// A tilted view's screen rows jump from the first metres to the horizon:
// keep the ground around the camera too. Looking down needs no help.
nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null,
});
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (zoom == null || !bbox) {
state.coverage.hint =
zoom == null && Number.isFinite(height) && messages.tooHigh
? messages.tooHigh
: 'Point the camera at the globe for street-level coverage';
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
retire();
state.coverage.zoom = zoom;
state.coverage.kind = kind;
}
const { tiles } = tilesForBbox(bbox, zoom, {
limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles,
focus: focus && { from: focus.nadir, to: focus.ahead },
});
const wanted = new Set(tiles.map(tileKey));
for (const key of [...state.coverage.tiles.keys()])
if (!wanted.has(key)) removeTile(key);
for (const [key, controller] of [...state.coverage.pending])
if (!wanted.has(key)) {
// The aborted request no longer owns its key, so its `finally` will
// not settle it: settle it here.
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
// The camera moved: cells that were out of the sampler's range may not be,
// and lines left draped by a failed, partial or dropped cast get another try.
if (terrainMode())
for (const entry of state.coverage.tiles.values()) {
if (entry.castRequested) requestMesh(entry);
if (entry.drapedLines) castTile(entry, { retry: true });
}
notify();
}
function scheduleRefresh() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs);
}
function attach(viewer) {
detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => {
remove();
removeEnd();
};
refresh();
}
function detach() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = null;
state.coverage.removeCameraListener?.();
state.coverage.removeCameraListener = null;
}
function clear() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
clearTimeout(state.coverage.remeshTimer);
state.coverage.remeshTimer = null;
state.coverage.remeshDirty.clear();
stopSelectionWatch();
horizon.stop();
requestRender();
}
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
entry.meshParts = null;
detachPrimitive(entry);
attachPrimitive(entry);
}
requestRender();
notify();
}
/**
* Load every tile again (a source that filters on its server had its
* filter changed); the lines drawn now show until the new ones are in.
*/
function reload() {
retire();
refresh();
}
/**
* Redraw tiles draped or cast for the new surface mode, then re-pick the
* zoom, since the ground height under the camera may have changed.
*/
function setSurface() {
if (!state.context.isActive()) return;
rebuild();
refresh();
}
/** Recolour every part of a sequence in one ready primitive. */
function recolorInstances(primitive, sequence, selected) {
const value = sequenceColor({ selected });
for (const instanceId of partIds(sequence, pickPrefix)) {
try {
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
if (attributes)
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
value,
attributes.color,
);
} catch {
/* instance not in this primitive */
}
}
}
/** Every tile on the globe: the current zoom's and the stale zoom's. */
function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
function drawnRecords(entry) {
const records = entry.primitives || [];
return entry.swap ? [...records, ...entry.swap.old] : records;
}
/** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) {
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const record of drawnRecords(entry)) {
// Still building: `syncSelection` catches it up once it is ready.
if (!record.primitive.ready) continue;
recolorInstances(record.primitive, sequence, selected);
if (selected) record.selectedId = id;
else if (record.selectedId === id) record.selectedId = null;
}
}
requestRender();
}
/**
* Bring a ready primitive's highlight up to the current selection, when
* it was built with another one (or none). True when it changed.
*/
function syncSelection(entry, record) {
const current = state.sequence.selectedId ?? null;
if (record.selectedId === current || !record.primitive.ready) return false;
const previous =
record.selectedId && entry.sequences.get(record.selectedId);
if (previous) recolorInstances(record.primitive, previous, false);
const next = current && entry.sequences.get(current);
if (next) recolorInstances(record.primitive, next, true);
record.selectedId = current;
return true;
}
/**
* A primitive cannot be recoloured until it is ready, so while any is
* building, catch each one up with the selection as it becomes ready.
*/
function watchSelection() {
const scene = state.viewer?.scene;
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
const stop = scene.postRender.addEventListener(() => {
let building = false;
let changed = false;
for (const entry of drawnEntries()) {
for (const record of entry.primitives || []) {
if (!record.primitive.ready) building = true;
else if (syncSelection(entry, record)) changed = true;
}
// Lines a swap still shows were ready (or never show): catch up only.
for (const record of entry.swap?.old || [])
if (syncSelection(entry, record)) changed = true;
}
if (changed) requestRender();
if (!building) stopSelectionWatch();
});
state.coverage.stopSelectionWatch = stop;
}
function stopSelectionWatch() {
state.coverage.stopSelectionWatch?.();
state.coverage.stopSelectionWatch = null;
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
*/
function sequenceCount() {
const ids = new Set();
let points = 0;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const id of entry.sequences.keys()) ids.add(id);
else points += entry.count;
return ids.size + points;
}
return {
attach,
detach,
refresh,
clear,
resetErrors,
rebuild,
reload,
setSurface,
recolorSequence,
sequenceCount,
};
}
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLineCoverage } from './lineCoverage.js';
import {
coverageViewer,
tileCentre,
} from '../../testSupport/streetLevelFakes.mjs';
import { until } from '../../testSupport/kartaViewFakes.mjs';
const TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 0,
maxSequences: 900,
lineWidthPx: 2,
overviewPointPx: 2,
moveDebounceMs: 10,
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
viewNearM: 1_000,
});
/**
* Line coverage over one tile holding one line, on a viewer looking down from
* `height`. `fetchTile` answers with that line unless `fail` says otherwise.
*/
function setup(
t,
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = coverageViewer(
{ lon: centre.lon, lat: centre.lat, height: 900 },
t,
);
const state = {
viewer,
services: {},
keyRequired: false,
keyRejected: false,
context: {
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
getSurface: () => 'draped',
groundCaster: null,
meshSampler: null,
},
coverage: {
zoom: null,
kind: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: Promise.resolve(),
hint: '',
},
sequence: { selectedId: null },
};
const asked = [];
const coverage = createLineCoverage({
state,
fetchTile: async (tile) => {
asked.push(tile);
if (fail) throw fail;
return {
sequences: [
{
id: 'line-1',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
capturedAt: 0,
isPano: false,
},
],
overview: [],
};
},
zoomFor:
zoomFor ??
((height) =>
Number.isFinite(height) && height <= 6_000
? { zoom: 14, kind: 'sequence' }
: null),
colors: { coverage: '#635bff', selected: '#00e5ff' },
pickPrefix: 'test:line:',
renderKey: 'test-coverage',
tuning: TUNING,
messages,
...(passes ? { passes } : {}),
});
t.after(() => {
coverage.clear();
coverage.resetErrors();
});
return { viewer, state, coverage, asked };
}
test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => {
const tooHigh = 'zoom in to see test coverage';
const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } });
high.viewer.view.height = 7_000;
high.coverage.refresh();
assert.equal(high.state.coverage.hint, tooHigh);
assert.equal(high.state.coverage.zoom, null);
assert.equal(high.asked.length, 0, 'nothing is asked from up there');
const generic = setup(t);
generic.viewer.view.height = 7_000;
generic.coverage.refresh();
assert.notEqual(generic.state.coverage.hint, tooHigh);
assert.match(generic.state.coverage.hint, /Point the camera/);
});
test('a provider’s passes() decides which lines are drawn and counted', async (t) => {
const all = setup(t, { passes: () => true });
all.coverage.refresh();
assert.ok(await until(() => all.state.coverage.tiles.size === 1));
assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn');
const none = setup(t, { passes: () => false });
none.coverage.refresh();
assert.ok(await until(() => none.state.coverage.tiles.size === 1));
assert.equal(none.coverage.sequenceCount(), 0, 'filtered out');
});
test('a refused key without its own message keeps the error it came with', async (t) => {
const refused = Object.assign(new Error('the source refused the key'), {
keyRejected: true,
});
const { state, coverage } = setup(t, { fail: refused });
coverage.refresh();
assert.ok(await until(() => state.keyRejected === true));
assert.equal(state.coverage.lastError, 'the source refused the key');
});
+1
View File
@@ -8,6 +8,7 @@ export const POSITION_PICK_ID = 'sl:pos';
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
google: '#4285f4',
kartaview: '#635bff',
});
/** Colours every provider shares: the selection highlight and the marker. */
@@ -0,0 +1,176 @@
import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js';
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
export const MAX_TILE_SEGMENTS = 20_000;
/** A chained line stops growing here and the next one starts. */
export const MAX_LINE_POINTS = 5_000;
/**
* The way segments of a KartaView coverage tile
* (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines
* carry no sequence, date or projection: a filter thins them through a mask
* (see `maskedSegments`).
* @param {object} payload
* @returns {Array<Array<[number, number]>>}
*/
export function tileSegments(payload) {
// Not cached as empty: an unreadable tile is asked for again.
if (!Array.isArray(payload?.lines))
throw new Error('KartaView sent an unreadable coverage tile');
const segments = [];
for (const line of payload.lines) {
if (segments.length >= MAX_TILE_SEGMENTS) break;
const points = [];
for (const point of Array.isArray(line?.track) ? line.track : []) {
const lon = Number(point?.lng);
const lat = Number(point?.lat);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const last = points[points.length - 1];
if (last && last[0] === lon && last[1] === lat) continue;
points.push([lon, lat]);
}
if (points.length >= 2) segments.push(points);
}
return segments;
}
/**
* Segments chained into line records for the shared coverage: segments
* that meet end to start are joined, which cuts the line count about
* fourfold.
* @param {Array<Array<[number, number]>>} segments
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
*/
export function chainSegments(segments, { x, y, z }) {
const key = ([lon, lat]) => `${lon},${lat}`;
const startingAt = new Map();
segments.forEach((segment, index) => {
const at = key(segment[0]);
const list = startingAt.get(at);
if (list) list.push(index);
else startingAt.set(at, [index]);
});
const used = new Uint8Array(segments.length);
// Per start point, how far its list has been used: each segment is looked
// at once, so a tile of segments sharing one point stays linear.
const cursor = new Map();
const nextFrom = (point) => {
const at = key(point);
const list = startingAt.get(at);
if (!list) return undefined;
let i = cursor.get(at) ?? 0;
while (i < list.length && used[list[i]]) i++;
cursor.set(at, i);
return list[i];
};
const sequences = [];
segments.forEach((segment, index) => {
if (used[index]) return;
used[index] = 1;
const chain = [...segment];
for (;;) {
const next = nextFrom(chain[chain.length - 1]);
if (next === undefined) break;
// Full: the segment starts the next line instead.
if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break;
used[next] = 1;
chain.push(...segments[next].slice(1));
}
sequences.push({
id: `${z}/${x}/${y}/${sequences.length}`,
parts: [chain],
capturedAt: 0,
isPano: false,
});
});
return { sequences, overview: [] };
}
/** Decode a KartaView coverage tile into the shared coverage's line records. */
export function decodeCoverageTile(payload, address) {
return chainSegments(tileSegments(payload), address);
}
/**
* The segments a filtered PNG tile of the same address draws over. The PNG
* draws the same ways a few pixels wide (on a transparent ground): a
* segment is kept when at least half the points sampled along it have a
* drawn pixel within one pixel. Segments either lie on it or miss it
* almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33
* between). A segment with no point on the tile is not this tile's to keep.
* @param {Array<Array<[number, number]>>} segments
* @param {{width: number, height: number, alpha: ArrayLike<number>}} mask one alpha byte per pixel
* @param {{x: number, y: number, z: number}} address
*/
export function maskedSegments(segments, mask, { x, y, z }) {
const { width, height, alpha } = mask;
const n = 2 ** z;
const toPixel = ([lon, lat]) => {
const sin = Math.sin((lat * Math.PI) / 180);
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height];
};
const drawnNear = (px, py) => {
const cx = Math.floor(px);
const cy = Math.floor(py);
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
const X = cx + dx;
const Y = cy + dy;
if (X < 0 || Y < 0 || X >= width || Y >= height) continue;
if (alpha[Y * width + X] > 0) return true;
}
return false;
};
return segments.filter((segment) => {
let on = 0;
let tested = 0;
let [ax, ay] = toPixel(segment[0]);
for (let i = 1; i < segment.length; i++) {
const [bx, by] = toPixel(segment[i]);
const steps = Math.max(
1,
Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX),
);
// Each point once: a segment's next stretch starts where this one ends.
for (let j = i === 1 ? 0 : 1; j <= steps; j++) {
const px = ax + ((bx - ax) * j) / steps;
const py = ay + ((by - ay) * j) / steps;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
tested++;
if (drawnNear(px, py)) on++;
}
ax = bx;
ay = by;
}
return tested > 0 && on / tested >= MASK_KEEP_SHARE;
});
}
/**
* A PNG mask's alpha, one byte per pixel, decoded off the main thread
* where the browser can.
* @param {Uint8Array} bytes the PNG
* @returns {Promise<{width: number, height: number, alpha: Uint8Array}>}
*/
export async function readMaskImage(bytes) {
const bitmap = await createImageBitmap(
new Blob([bytes], { type: 'image/png' }),
);
try {
const { width, height } = bitmap;
const canvas =
typeof OffscreenCanvas === 'function'
? new OffscreenCanvas(width, height)
: Object.assign(document.createElement('canvas'), { width, height });
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(bitmap, 0, 0);
const rgba = context.getImageData(0, 0, width, height).data;
const alpha = new Uint8Array(width * height);
for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
return { width, height, alpha };
} finally {
bitmap.close?.();
}
}
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MAX_LINE_POINTS,
MAX_TILE_SEGMENTS,
chainSegments,
decodeCoverageTile,
maskedSegments,
tileSegments,
} from './decode.js';
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
const TILE = { x: 4412, y: 6061, z: 14 };
test('segments that meet end to start chain into one line', () => {
const { sequences, overview } = decodeCoverageTile(
{
lines: [
{ track: [at(1, 1), at(2, 1)], matched: '1' },
{ track: [at(5, 5), at(6, 6)], matched: '1' },
{ track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' },
],
},
TILE,
);
assert.deepEqual(overview, []);
assert.equal(sequences.length, 2);
assert.deepEqual(sequences[0].parts, [
[
[1, 1],
[2, 1],
[3, 1],
[4, 2],
],
]);
assert.equal(sequences[0].id, '14/4412/6061/0');
assert.equal(sequences[1].id, '14/4412/6061/1');
// Nothing to date or project: every line passes as flat and undated.
assert.equal(sequences[0].capturedAt, 0);
assert.equal(sequences[0].isPano, false);
});
test('a junction continues one way only, and every segment is drawn exactly once', () => {
const { sequences } = decodeCoverageTile(
{
lines: [
{ track: [at(0, 0), at(1, 0)] },
{ track: [at(1, 0), at(2, 0)] },
{ track: [at(1, 0), at(1, 1)] },
// A loop back to its own start must end, not spin.
{ track: [at(9, 9), at(9, 8)] },
{ track: [at(9, 8), at(9, 9)] },
],
},
TILE,
);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
assert.equal(sequences.length, 3);
// 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points.
assert.equal(points, 8);
});
test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => {
const { sequences } = decodeCoverageTile(
{
lines: [
{ track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] },
{ track: [at(3, 3)] },
{ track: null },
null,
],
},
TILE,
);
assert.equal(sequences.length, 1);
assert.deepEqual(sequences[0].parts[0], [
[1, 1],
[2, 2],
]);
assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []);
});
test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => {
for (const payload of [
null,
{},
{ lines: 'nope' },
{ status: { apiCode: 690 } },
])
assert.throws(
() => decodeCoverageTile(payload, TILE),
/unreadable coverage tile/,
);
});
test('a tile whose segments all share one point decodes in linear time', () => {
// [P, Q_i, P]: every chain step looks at the same start point's list.
// 60k: the old quadratic chaining took over a second; capped and linear
// decoding takes tens of milliseconds.
const lines = Array.from({ length: 60_000 }, (_, i) => ({
track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)],
}));
const started = performance.now();
const { sequences } = decodeCoverageTile({ lines }, TILE);
const ms = performance.now() - started;
assert.ok(
ms < 1_000,
`${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`,
);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
assert.equal(
points,
MAX_TILE_SEGMENTS * 2 + sequences.length,
'every segment drawn once',
);
});
test('a tile is capped in segments and in points per line', () => {
const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({
track: [at(i, 0), at(i + 1, 0)],
}));
const { sequences } = decodeCoverageTile({ lines }, TILE);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
// One long chain, cut into lines of at most MAX_LINE_POINTS.
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length);
assert.ok(
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
);
});
/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */
function lonLatAt(px, py, { x, y, z } = TILE, size = 512) {
const n = 2 ** z;
const lon = ((x + px / size) / n) * 360 - 180;
const my = Math.PI * (1 - (2 * (y + py / size)) / n);
const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI;
return [lon, lat];
}
/** A 512 px mask drawn on the given rows. */
function rowsMask(rows) {
const alpha = new Uint8Array(512 * 512);
for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512);
return { width: 512, height: 512, alpha };
}
test('a mask keeps the segments it draws over, chained as before', () => {
const along = (row, from = 40, to = 470) => [
lonLatAt(from, row),
lonLatAt(to, row),
];
const segments = [
along(100), // drawn at row 100
along(101.6), // a pixel off: still drawn within one pixel
along(300), // not drawn
// Two thirds along the drawn row, then off it: kept (at least half).
[lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)],
// A third along it, then off: dropped.
[lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)],
// Off the tile entirely: not this tile's to keep.
[lonLatAt(-200, 100), lonLatAt(-20, 100)],
];
const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE);
assert.deepEqual(kept, [segments[0], segments[1], segments[3]]);
assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []);
// A full mask keeps every segment that is on the tile.
assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5);
});
test('decoding is segments then chaining: the same lines either way', () => {
const payload = {
lines: [
{ track: [at(1, 1), at(2, 1)] },
{ track: [at(2, 1), at(3, 1)] },
{ track: [at(5, 5), at(6, 6)] },
],
};
const segments = tileSegments(payload);
assert.equal(segments.length, 3);
assert.deepEqual(
chainSegments(segments, TILE),
decodeCoverageTile(payload, TILE),
);
assert.throws(() => tileSegments({}), /unreadable coverage tile/);
});
@@ -0,0 +1,649 @@
import { createLineCoverage } from '../../lineCoverage.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { metresBetween } from '../../view.js';
import {
chainSegments,
maskedSegments,
readMaskImage,
tileSegments,
} from './decode.js';
import { createKartaViewSource } from './source.js';
import { createKartaViewViewer } from './viewer.js';
import {
ARCHIVED_MESSAGE,
COLORS,
COVERAGE_MAX_HEIGHT_M,
COVERAGE_NOUN,
COVERAGE_STREET_ZOOM_BELOW_M,
COVERAGE_TUNING,
FILTERED_OUT_MESSAGE,
KARTAVIEW_CREDIT_HTML,
KARTAVIEW_LABEL,
KARTAVIEW_NAME,
KARTAVIEW_PROVIDER_ID,
LINE_CLICK_HINT,
NEAREST_PROBES,
NEAREST_RADIUS_M,
NO_COVERAGE_HINT,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
SEQUENCE_PAGE_EDGE,
SEQUENCE_PAGE_SIZE,
TOO_HIGH_HINT,
assignCourses,
cameraBearing,
normalizePhoto,
passesKartaViewFilter,
tileFilterQuery,
} from './policy.js';
/** Photos kept in memory by id (sequence pages fill it). */
const PHOTO_CACHE_SIZE = 4000;
/** Sequence pages kept in memory. */
const PAGE_CACHE_SIZE = 12;
/** Decoded coverage tiles kept, and for how long (masks too). */
const TILE_CACHE_SIZE = 24;
const TILE_CACHE_TTL_MS = 10 * 60_000;
/** z14 near the street, z13 a little higher, nothing above. */
export function kartaViewZoomFor(heightM) {
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
return {
zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14,
kind: 'sequence',
};
}
/** Least-recently-used map: reading an entry renews it. */
function createLru(limit) {
const map = new Map();
return {
get(key) {
if (!map.has(key)) return undefined;
const value = map.get(key);
map.delete(key);
map.set(key, value);
return value;
},
set(key, value) {
map.delete(key);
map.set(key, value);
while (map.size > limit) map.delete(map.keys().next().value);
},
delete: (key) => map.delete(key),
clear: () => map.clear(),
};
}
/**
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
* a pan back or a zoom across z13/z14 would fetch them all again.
*/
export function createTileCache({
load,
size = TILE_CACHE_SIZE,
ttlMs = TILE_CACHE_TTL_MS,
now = Date.now,
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
}) {
const kept = createLru(size);
return {
async get(tile, options) {
const key = keyOf(tile);
const entry = kept.get(key);
if (entry && now() - entry.at < ttlMs) return entry.tile;
const loaded = await load(tile, options);
kept.set(key, { at: now(), tile: loaded });
return loaded;
},
/** Drop every tile (the provider was switched off). */
clear: () => kept.clear(),
};
}
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
function pageOf(index) {
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
}
/**
* KartaView Street Level provider: keyless coverage lines from KartaView's
* tile API, the photos nearest a clicked line, cones along the photo's
* sequence, and a flat or 360° viewer with ‹ › along the sequence.
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object, readMask?: typeof readMaskImage}} [options]
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createKartaViewProvider({
source = createKartaViewSource(),
viewerOptions = {},
readMask = readMaskImage,
} = {}) {
return Object.freeze({
id: KARTAVIEW_PROVIDER_ID,
name: KARTAVIEW_NAME,
label: KARTAVIEW_LABEL,
requiresKeyId: null,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
// Its own marker, as Street View has: a purple cone that turns with the view.
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
defaultOn: false,
// 360° / flat and SINCE, as Mapillary: KartaView filters the lines on
// its server (see tileFilterQuery), the photos by their own record.
filters: Object.freeze(['pano', 'since']),
// SINCE is the upload date for KartaView: the panel notes it.
sinceBasis: 'upload',
// Its lines are streets, not sequences: a selected drive shows as cones.
highlightsSequence: false,
coverageLabel: Object.freeze({
noun: COVERAGE_NOUN,
action: LINE_CLICK_HINT,
}),
create(context) {
const state = {
context,
services: context.services,
viewer: null,
keyRequired: false,
keyRejected: false,
statusKnown: true,
coverage: {
zoom: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
};
const photos = createLru(PHOTO_CACHE_SIZE);
const pages = createLru(PAGE_CACHE_SIZE);
/** The photo last opened in each sequence: its cones centre on it. */
const anchors = new Map();
/** The sequence page the cones were drawn from. */
let drawnPage = null;
/** A record as a photo; the one already known keeps its identity (and course). */
function remember(record) {
const fresh = normalizePhoto(record);
if (!fresh) return null;
const known = photos.get(fresh.id);
const photo = known ? Object.assign(known, fresh) : fresh;
photos.set(photo.id, photo);
return photo;
}
async function getPhoto(photoId, { signal } = {}) {
const cached = photos.get(photoId);
if (cached) return cached;
const photo = remember(await source.getPhoto(photoId, { signal }));
if (!photo) throw new Error('KartaView has no position for this photo');
return photo;
}
/** A page of a sequence's photos, in index order (shared while in flight). */
function loadPage(sequenceId, page) {
const key = `${sequenceId}:${page}`;
let entry = pages.get(key);
if (!entry) {
entry = source
.getSequencePhotos(sequenceId, {
page,
itemsPerPage: SEQUENCE_PAGE_SIZE,
})
.then(({ photos: records, hasMore }) => {
const list = assignCourses(
records
.map(remember)
.filter(Boolean)
.sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
metresBetween,
);
// The open photo may have just learnt which way it looked. The
// page is good whatever the viewer is doing.
try {
viewer.refreshPose?.();
} catch {
/* the next pose carries the direction */
}
return { page, hasMore, photos: list };
});
pages.set(key, entry);
entry.catch(() => pages.delete(key));
}
return entry;
}
/**
* The page holding `photo`. Pages follow photo ids, which only roughly
* follow the index, and photos go missing, so the page the index
* suggests is a first guess: step towards the photo until a page holds
* it, or the pages straddle where it would be.
*/
async function homePage(photo) {
let number = pageOf(photo.sequenceIndex ?? 0);
const index = photo.sequenceIndex ?? 0;
let page = await loadPage(photo.sequenceId, number);
if (!page.photos.length && number > 1) {
// Past the end (many photos deleted): find the last page by halving.
let low = 1;
let high = number - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if ((await loadPage(photo.sequenceId, middle)).photos.length)
low = middle;
else high = middle - 1;
}
number = low;
}
const seen = new Set();
while (!seen.has(number) && seen.size < 8) {
seen.add(number);
page = await loadPage(photo.sequenceId, number);
if (page.photos.some((p) => p.id === photo.id)) return page;
const first = page.photos[0]?.sequenceIndex;
const last = page.photos.at(-1)?.sequenceIndex;
// Past the last page, or before this page's photos: go back.
if ((first === undefined || index < first) && number > 1) number--;
else if (last !== undefined && index > last && page.hasMore) number++;
else break;
}
return page;
}
/**
* The photos around `photo` in index order: its page, plus a
* neighbour page when it sits near an edge (pages overlap in index).
* @returns {Promise<{home: number, photos: Array<object>}>}
*/
async function windowOf(photo) {
const home = await homePage(photo);
const list = home.photos;
const found = list.findIndex((p) => p.id === photo.id);
// A photo no page lists sits where its index would.
const at =
found !== -1
? found
: list.filter(
(p) => (p.sequenceIndex ?? 0) < (photo.sequenceIndex ?? 0),
).length;
const parts = [list];
if (at < SEQUENCE_PAGE_EDGE && home.page > 1)
parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos);
if (at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos);
if (parts.length === 1) return { home: home.page, photos: list };
const byId = new Map(parts.flat().map((p) => [p.id, p]));
return {
home: home.page,
photos: [...byId.values()].sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
};
}
/**
* Photo id → whether its file is there (archived ones are not). Only
* a definite answer is kept: a storage hiccup is asked again next
* time, and meanwhile the photo is tried. A probe is shared, so it
* runs to its own timeout rather than any one caller's abort.
*/
const probes = createLru(PHOTO_CACHE_SIZE);
function showable(photo) {
if (!photo?.urls?.view) return Promise.resolve(false);
let probe = probes.get(photo.id);
if (!probe) {
probe = source.probeImage(photo.urls.view).then(
(answer) => {
if (answer === null) probes.delete(photo.id);
return answer !== false;
},
() => {
probes.delete(photo.id);
return true;
},
);
probes.set(photo.id, probe);
}
return probe;
}
// A tile's way segments, kept a while: panning back, or a filter
// change, asks KartaView nothing more for them.
const tiles = createTileCache({
load: async (tile, { signal }) =>
tileSegments(await source.getTile(tile, { signal })),
});
// A filtered tile's mask, by filter and tile.
const masks = createTileCache({
load: async ({ query, ...tile }, { signal }) =>
readMask(await source.getTileMask(tile, query, { signal })),
keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`,
});
/** The tile's lines under the filter now: all, or the masked ones. */
async function fetchTile(tile, options) {
const query = tileFilterQuery(context.getFilter());
if (!query) return chainSegments(await tiles.get(tile, options), tile);
const [segments, mask] = await Promise.all([
tiles.get(tile, options),
masks.get({ ...tile, query }, options),
]);
return chainSegments(maskedSegments(segments, mask, tile), tile);
}
/** The query the drawn lines were loaded under. */
let drawnQuery = tileFilterQuery(context.getFilter());
/**
* The next showable photo `delta` along the sequence, or null at its
* end. Archived photos are skipped, a few at most.
*/
async function step(photo, delta) {
if (!photo?.sequenceId) return null;
let from = photo;
for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) {
const { photos: list } = await windowOf(from);
let at = list.findIndex((p) => p.id === from.id);
if (at === -1) {
// Not found anywhere: the nearest index on the right side.
const index = from.sequenceIndex ?? 0;
const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index);
if (delta > 0 && later === -1) return null;
at =
delta > 0
? later - 1
: later === -1
? list.length
: list.findIndex((p) => (p.sequenceIndex ?? 0) >= index);
}
const next = list[at + delta];
if (!next) return null;
if (await showable(next)) return next.id;
from = next;
}
return null;
}
const coverage = createLineCoverage({
state,
fetchTile,
zoomFor: kartaViewZoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.line,
renderKey: 'kartaview-coverage',
tuning: COVERAGE_TUNING,
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT },
// Filtered on the way in (fetchTile): a line has no date to test.
passes: () => true,
});
const cones = createSequenceCones({
state,
async loadImages(sequenceId, { signal }) {
let anchor = anchors.get(sequenceId);
if (!anchor) return [];
let window = await windowOf(anchor);
signal?.throwIfAborted();
// Another photo of the drive opened while that page loaded (its
// re-centre found nothing drawn yet): centre on the newest one.
while (anchors.get(sequenceId) !== anchor) {
anchor = anchors.get(sequenceId);
window = await windowOf(anchor);
signal?.throwIfAborted();
}
drawnPage = { sequenceId, page: window.home };
countPhotos(sequenceId);
return thinImages(
window.photos.map((photo) => ({
id: photo.id,
lon: photo.lon,
lat: photo.lat,
compassAngle: cameraBearing(photo) ?? 0,
capturedAt: photo.capturedAt ?? 0,
uploadedAt: photo.uploadedAt,
isPano: photo.isPano,
})),
);
},
colors: COLORS,
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:kartaview-cones',
renderKey: 'kartaview-sequence',
// By projection and upload date, as the lines.
passes: passesKartaViewFilter,
});
/** Sequence id → its photo count from KartaView (asked once). */
const counts = new Map();
function countPhotos(sequenceId) {
if (counts.has(sequenceId) || !source.getSequenceCount) return;
counts.set(sequenceId, undefined);
source.getSequenceCount(sequenceId).then(
(count) => {
if (count === null) counts.delete(sequenceId);
else {
counts.set(sequenceId, count);
context.notify();
}
},
() => counts.delete(sequenceId),
);
}
/** ‹ › open through the core (loading, errors, cones), camera still. */
function openStepped(photoId) {
context.actions.openImage(photoId, { frame: false });
}
/**
* The cones show one page of a sequence: once a photo of the selected
* sequence opens on another page (‹ ›, a cone, a line), re-centre them.
*/
async function recentreCones(photo) {
if (!photo?.sequenceId) return;
if (state.sequence.selectedId !== photo.sequenceId) return;
const drawn = drawnPage;
if (drawn?.sequenceId !== photo.sequenceId) return;
const window = await windowOf(photo).catch(() => null);
// Still the drawn page and the latest photo once the page is in.
if (
window &&
drawnPage === drawn &&
window.home !== drawn.page &&
anchors.get(photo.sequenceId) === photo
)
cones.select(photo.sequenceId, { force: true });
}
const viewer = createKartaViewViewer({
getPhoto: async (photoId) => {
const photo = await getPhoto(photoId);
if (photo.sequenceId) anchors.set(photo.sequenceId, photo);
return photo;
},
step,
onStep: openStepped,
onShown: recentreCones,
// After a GPU reset the same photo is drawn again by the viewer
// itself: going through the core would cancel a lookup the user
// started. A failure still shows.
onReopen: (photoId) =>
viewer
.open(photoId)
.catch((error) =>
context.actions.reportError(
error?.message || 'The KartaView photo could not be loaded',
),
),
render: context.services?.render,
...viewerOptions,
});
return {
async status() {
return { configured: true };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
cones.ensureCollections(cesiumViewer);
cones.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
cones.setVisible(true);
coverage.attach(cesiumViewer);
},
deactivate() {
coverage.detach();
coverage.clear();
coverage.resetErrors();
cones.clearSelection();
cones.setVisible(false);
drawnPage = null;
// Tens of MB of decoded lines are not kept for a source left off.
tiles.clear();
masks.clear();
},
destroy(cesiumViewer) {
coverage.detach();
coverage.clear();
coverage.resetErrors();
cones.destroy(cesiumViewer);
tiles.clear();
masks.clear();
},
refreshCoverage: () => coverage.refresh(),
/**
* The filter changed: the lines come from KartaView again under it
* (the old ones show meanwhile), the cones are drawn again.
*/
setFilter() {
const query = tileFilterQuery(context.getFilter());
if (query !== drawnQuery) {
drawnQuery = query;
coverage.reload();
}
cones.rerender();
},
setSurface() {
coverage.setSurface();
cones.rerender();
},
coverageStats() {
const { hint } = state.coverage;
const count = coverage.sequenceCount();
const loading = state.coverage.pending.size > 0;
const empty =
state.coverage.zoom !== null &&
!loading &&
count === 0 &&
!state.coverage.lastError;
return {
count,
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading,
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
error: state.coverage.lastError,
keyRequired: false,
keyRejected: false,
};
},
/** A line opens the photo nearest the click; a cone opens its photo. */
handlePick(pickId, { groundPoint } = {}) {
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
if (pickId.startsWith(PICK_PREFIX.line)) {
const point = groundPoint?.();
if (point) context.actions.openNearest(point);
return true;
}
return false;
},
// Never from the cache: it is keyed by sequence, the cones by page.
selectSequence: (sequenceId) =>
cones.select(sequenceId, { force: true }),
clearSequence: () => {
cones.clearSelection();
drawnPage = null;
},
// The drive's own count, not the thinned page of it the cones show.
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images:
counts.get(state.sequence.selectedId) ??
state.sequence.images.length,
loading: state.sequence.loading,
}),
/**
* The nearest photo within the search radius that can be shown, or
* null. Many photos are archived (their files answer 409) or have no
* file at all, so the nearest few are tried in turn.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const filter = context.getFilter();
const records = await source.nearestPhotos(
{ lat, lon, filter },
{ signal },
);
const near = [];
let filteredOut = 0;
for (const record of records) {
const photo = remember(record);
if (!photo?.urls.view) continue;
const metres = metresBetween({ lat, lon }, photo);
if (metres > NEAREST_RADIUS_M) continue;
if (!passesKartaViewFilter(photo, filter)) filteredOut++;
else near.push({ photo, metres });
}
near.sort((a, b) => a.metres - b.metres);
// Probed side by side (photo storage is not the API host): the
// nearest one that answers wins.
const candidates = near.slice(0, NEAREST_PROBES);
const answers = await Promise.all(
candidates.map(({ photo }) => showable(photo, { signal })),
);
const hit = candidates.find((_, i) => answers[i]);
if (hit) return hit.photo.id;
if (near.length) throw new Error(ARCHIVED_MESSAGE);
if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE);
return null;
},
viewer,
};
},
});
}
@@ -0,0 +1,243 @@
/**
* 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,
);
// 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(image.width) && isPowerOfTwo(image.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,169 @@
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);
});
@@ -0,0 +1,397 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the KartaView street-level provider (keyless). */
export const KARTAVIEW_PROVIDER_ID = 'kartaview';
export const KARTAVIEW_NAME = 'KartaView';
export const KARTAVIEW_LABEL = 'KARTAVIEW';
/**
* The public API. Its documented twin, api.openstreetcam.org, still answers
* but serves empty coverage tiles.
*/
export const KARTAVIEW_API_HOST = 'https://api.kartaview.org';
/** KartaView's image proxy: resizes a photo given its base64url address. */
export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
/** Stable id prefixes for picked primitives; every one starts with `kv:`. */
export const PICK_PREFIX = Object.freeze({
root: 'kv:',
line: 'kv:line:',
image: 'kv:img:',
});
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.kartaview,
selected: SHARED_COLORS.selected,
});
/**
* Coverage lines are OSM ways with photos, from z13–14 JSON tiles. Lower
* zooms take seconds per tile (z10 does not answer), so higher up the panel
* asks the user to zoom in.
*/
export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500;
export const COVERAGE_MAX_HEIGHT_M = 6_000;
export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 0,
// Chained ways are short: a dense downtown z13 tile holds ~1,500, so each
// tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn).
maxSequences: 14_400,
lineWidthPx: 2.5,
overviewPointPx: 2.5,
moveDebounceMs: 320,
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
viewNearM: 1_000,
});
export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage';
export const NO_COVERAGE_HINT = 'no KartaView photos in view';
export const RATE_LIMITED_MESSAGE =
'KartaView is rate-limiting requests — they resume on their own';
/**
* The photo API answers 429 to more than about three requests at once, so
* at most two are in flight; a 429 is retried after a short wait.
*/
export const PHOTO_API_CONCURRENCY = 2;
export const PHOTO_API_RETRIES = 3;
export const PHOTO_API_RETRY_BASE_MS = 800;
/** A Retry-After longer than this is not waited out. */
export const PHOTO_API_RETRY_MAX_MS = 10_000;
/** The API is HTTP/1.1 (six connections): tiles 3, lookups 2, pages 1. */
export const TILE_CONCURRENCY = 3;
export const PAGE_CONCURRENCY = 1;
export const TILE_TIMEOUT_MS = 20_000;
export const PHOTO_API_TIMEOUT_MS = 15_000;
/**
* The first try of a lookup: KartaView sometimes sits on a lookup for 11 s
* and more, then answers the same one again in 3 s.
*/
export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000;
/** A sequence page is about 650 kB of JSON. */
export const PAGE_TIMEOUT_MS = 30_000;
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
/** Whether a photo file is there (not archived): headers only. */
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */
export const NEAREST_PROBES = 6;
export const ARCHIVED_MESSAGE =
'KartaView’s photos here are archived and cannot be shown';
/** Photos asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10;
/** The photo API's search zoom: 18 searches the few tens of metres around the point. */
export const NEAREST_SEARCH_ZOOM = 18;
/**
* Photos of a sequence fetched at once, around the open one. Records are
* ~2.6 kB each, so a page is ~650 kB; the cones and ‹ › use it. Pages are in
* photo-id order, which only roughly follows the sequence index.
*/
export const SEQUENCE_PAGE_SIZE = 250;
/** Within this many photos of a page's edge, its neighbour page joins it. */
export const SEQUENCE_PAGE_EDGE = 12;
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
/** Enough for a docked viewer; an expanded one gets the full size. */
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
/** A flat photo's first version, then its sharp one. */
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */
export const KARTAVIEW_CREDIT_HTML =
'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, CC BY-SA 4.0';
/**
* The imagery filter (360° or flat, SINCE). KartaView filters coverage on
* its server only in its PNG tiles: its JSON tiles come back empty once a
* filter is asked for. So a filtered PNG tile is a mask, and a JSON line is
* kept where it lies on the mask's drawn pixels.
*
* KartaView's date filter is the upload date, and SINCE goes by it
* throughout (lines, the photo a click opens, the cones), as KartaView's own
* map does; the panel says so under the slider.
*/
export const MASK_TIMEOUT_MS = 20_000;
/**
* A filtered PNG tile is drawn on demand and a dense one can outlast
* KartaView's own 60 s (504); it keeps drawing, and has the tile a few
* seconds later. A timeout or a 5xx is asked again this many times in all.
*/
export const MASK_ATTEMPTS = 4;
export const MASK_RETRY_WAIT_MS = 4_000;
/** Real masks are tens of kilobytes. */
export const MASK_MAX_BYTES = 2 * 1024 * 1024;
/** A segment is kept when this share of the points along it lies on the mask. */
export const MASK_KEEP_SHARE = 0.5;
/** Points along a segment are tested this many mask pixels apart. */
export const MASK_SAMPLE_PX = 2;
export const MASK_SLOW_MESSAGE =
'KartaView did not filter this area in time — move the map to ask again';
export const FILTERED_OUT_MESSAGE =
'None of KartaView’s photos here pass the imagery filter';
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = 'KartaView: by upload date';
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
function utcDay(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
const filtersSince = (filter) =>
Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0;
const DAY_MS = 86_400_000;
/**
* Whether a photo passes the resolved imagery filter by its projection and
* upload date, the cut-off taken as its UTC day, as KartaView's startDate.
* @param {{isPano: boolean, uploadedAt: number|null}} photo
* @param {{pano: string, sinceMs: number|null}|null} filter
*/
export function passesKartaViewFilter(photo, filter) {
if (!filter) return true;
if (filter.pano === 'pano' && !photo.isPano) return false;
if (filter.pano === 'flat' && photo.isPano) return false;
if (!filtersSince(filter)) return true;
const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS;
return (photo.uploadedAt ?? 0) >= day;
}
/**
* The query of a filtered PNG coverage tile, or '' when nothing is
* filtered. The day, not the instant, so it is stable for a day (and keys
* the mask cache).
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
*/
export function tileFilterQuery(filter) {
const params = new URLSearchParams();
if (filter?.pano === 'pano') params.set('fieldOfView', '360');
else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane');
if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs));
return params.toString();
}
/**
* The photo search's own filter: projection and upload date (its
* `fieldOfView` is ignored).
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
* @returns {Record<string, string>}
*/
export function photoFilterParams(filter) {
const params = {};
if (filter?.pano === 'pano') params.projection = 'SPHERE';
else if (filter?.pano === 'flat') params.projection = 'PLANE';
if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs);
return params;
}
/** The cone and coverage noun: a line is a street with photos, not a sequence. */
export const COVERAGE_NOUN = 'streets';
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
/** Base64url of a string, as the image proxy reads addresses. */
function base64Url(text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/** A photo resized by KartaView's image proxy to fit `width`×`height`. */
export function resizedImageUrl(url, [width, height]) {
if (!url) return null;
return `${KARTAVIEW_CDN_HOST}/pr:sharp/rs:fit:${width}:${height}/${base64Url(url)}`;
}
/** Epoch ms for a KartaView timestamp ('2016-02-19 15:28:18.000', UTC), or null. */
export function kartaViewTimeMs(value) {
const match =
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(
String(value ?? ''),
);
if (!match) return null;
const [, y, mo, d, h = '0', mi = '0', s = '0'] = match;
if (Number(y) < 1990) return null;
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
return Number.isFinite(ms) ? ms : null;
}
/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */
export function bearingBetween(a, b) {
const rad = Math.PI / 180;
const dLon = (b.lon - a.lon) * rad;
const y = Math.sin(dLon) * Math.cos(b.lat * rad);
const x =
Math.cos(a.lat * rad) * Math.sin(b.lat * rad) -
Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon);
return (Math.atan2(y, x) / rad + 360) % 360;
}
/** Photos closer than this say nothing about the direction of travel. */
const COURSE_MIN_STEP_M = 2;
/** How many photos along to look for one far enough away. */
const COURSE_SEARCH = 5;
/**
* Give each photo of a drive (in index order) its `course`: the direction
* of travel from it to the next photo a few metres on (from the previous one
* at the end). KartaView is dashcam imagery, so this is where the camera
* looks; its `heading` is the device compass, which reads 0 without one and
* can sit 110° off inside a car.
* @param {Array<{lon: number, lat: number, course?: number|null}>} photos
* @param {(a: object, b: object) => number} metres
*/
export function assignCourses(photos, metres) {
photos.forEach((photo, i) => {
let course = null;
for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++)
if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) {
course = bearingBetween(photo, photos[j]);
break;
}
for (
let k = i - 1;
course === null && k >= i - COURSE_SEARCH && k >= 0;
k--
)
if (metres(photos[k], photo) >= COURSE_MIN_STEP_M)
course = bearingBetween(photos[k], photo);
photo.course = course;
});
return photos;
}
/** Where the camera looked: the direction of travel, else the compass, else unknown. */
export function cameraBearing(photo) {
return photo?.course ?? photo?.heading ?? null;
}
/** A coordinate pair as numbers; the road-matched one when KartaView has it. */
function positionOf(record) {
const matchLat = Number(record.matchLat);
const matchLon = Number(record.matchLng);
if (
Number.isFinite(matchLat) &&
Number.isFinite(matchLon) &&
(matchLat !== 0 || matchLon !== 0)
)
return { lat: matchLat, lon: matchLon };
return { lat: Number(record.lat), lon: Number(record.lng) };
}
const STORAGE_HOST = /^storage\d+$/;
/** A KartaView photo-storage address, or null for anything else. */
function storageUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === 'https:' &&
/^storage\d+\.openstreetcam\.org$/.test(url.hostname)
? url.href
: null;
} catch {
return null;
}
}
/**
* A photo file's address. The API's `fileurl*` fields always name storage1,
* but a photo whose `storage` is another host (storage2…) is only there, so
* the address is built from `storage` and the relative `filepath*`. A
* `fileurl*` fallback must itself be on KartaView's storage.
*/
function fileUrl(record, pathKey, urlKey) {
const path = record[pathKey];
if (STORAGE_HOST.test(record.storage || '') && path)
return storageUrl(
`https://${record.storage}.openstreetcam.org/files/photo/${path}`,
);
return storageUrl(record[urlKey]);
}
/** A storage URL with its `{{sizeprefix}}` placeholder filled. */
function sized(record, prefix) {
const fill = (value) =>
typeof value === 'string' && value.includes('{{sizeprefix}}')
? value.replace('{{sizeprefix}}', prefix)
: null;
const path = fill(record.filepath);
return fileUrl(
{ ...record, filepath: path, fileurl: fill(record.fileurl) },
'filepath',
'fileurl',
);
}
/**
* A photo API record in the shape the provider uses, or null without an id
* or a position.
* @returns {{id: string, sequenceId: string|null, sequenceIndex: number|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, urls: {view: string|null, full: string|null, pano: string|null}}|null}
*/
export function normalizePhoto(record) {
if (!record || record.id == null) return null;
const { lat, lon } = positionOf(record);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
// 0,0 is a missing fix, not a photo in the Gulf of Guinea.
if (lat === 0 && lon === 0) return null;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
// An exact 0 is a device without a compass (whole drives read 0), not north.
const heading =
record.heading === '' || record.heading == null
? NaN
: Number(record.heading) || NaN;
const isPano =
record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360;
const full =
fileUrl(record, 'filepathProc', 'fileurlProc') || sized(record, 'proc');
const index = Number(record.sequenceIndex);
return {
id: String(record.id),
sequenceId: record.sequenceId != null ? String(record.sequenceId) : null,
sequenceIndex: Number.isFinite(index) ? index : null,
lon,
lat,
heading: Number.isFinite(heading) ? heading : null,
isPano,
capturedAt:
kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded),
// KartaView's SINCE goes by this (its server filters by upload date).
uploadedAt: kartaViewTimeMs(record.dateAdded),
urls: {
// A 1280 px rendition; for a 360° photo it is the front view only.
view:
fileUrl(record, 'filepathLTh', 'fileurlLTh') ||
sized(record, 'lth') ||
full,
full,
// A 360° photo's equirectangular image ('wrapped_proc').
pano: isPano && /wrapped/.test(full || '') ? full : null,
},
};
}
/**
* Where the image link goes. KartaView's photo pages fail for signed-out
* visitors ("Sequence cannot be loaded"), so it is the full-size photo
* through KartaView's image proxy, which also serves photos whose storage
* host answers 502 directly.
*/
export function kartaViewPhotoUrl(photo) {
const url = photo?.urls?.full || photo?.urls?.view;
return url
? `${KARTAVIEW_CDN_HOST}/pr:sharp/${base64Url(url)}`
: 'https://kartaview.org';
}
@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assignCourses,
bearingBetween,
cameraBearing,
kartaViewPhotoUrl,
kartaViewTimeMs,
normalizePhoto,
passesKartaViewFilter,
photoFilterParams,
resizedImageUrl,
tileFilterQuery,
} from './policy.js';
/** A photo record as /2.0/photo returns it (trimmed to what is read). */
function record(overrides = {}) {
return {
id: '7102491',
sequenceId: '14010',
sequenceIndex: '950',
lat: '42.335712',
lng: '-83.048301',
matchLat: '42.335731506347656',
matchLng: '-83.04828643798828',
heading: '299.58',
fieldOfView: '0',
projection: 'PLANE',
shotDate: '2016-08-23 20:17:47.000',
dateAdded: '2016-08-24 04:01:07',
storage: 'storage1',
filepath: '2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg',
filepathLTh: '2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
filepathProc: '2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
fileurl:
'https://storage1.openstreetcam.org/files/photo/2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg',
fileurlLTh:
'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
fileurlProc:
'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
...overrides,
};
}
test('a photo record normalizes to numbers, the road-matched position and UTC capture time', () => {
const photo = normalizePhoto(record());
assert.equal(photo.id, '7102491');
assert.equal(photo.sequenceId, '14010');
assert.equal(photo.sequenceIndex, 950);
assert.equal(photo.lat, 42.335731506347656);
assert.equal(photo.lon, -83.04828643798828);
assert.equal(photo.heading, 299.58);
assert.equal(photo.isPano, false);
assert.equal(photo.capturedAt, Date.UTC(2016, 7, 23, 20, 17, 47));
assert.equal(photo.urls.pano, null);
});
test('photo files come from the storage host the record names, not the one its URLs say', () => {
// The API writes storage1 into every URL; this photo lives on storage2.
const photo = normalizePhoto(record({ storage: 'storage2' }));
assert.equal(
photo.urls.view,
'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
);
assert.equal(
photo.urls.full,
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
);
// The link goes through the image proxy, which serves photos whose
// storage host answers 502 directly.
const link = kartaViewPhotoUrl(photo);
assert.match(link, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/[\w-]+$/);
assert.equal(
Buffer.from(link.split('/').at(-1), 'base64url').toString(),
photo.urls.full,
);
// A fallback address must be on KartaView's storage, over https.
for (const fileurlLTh of [
'https://tracker.example/x.jpg',
'http://storage1.openstreetcam.org/a.jpg',
'https://storage1.openstreetcam.org.evil.example/a.jpg',
'javascript:alert(1)',
]) {
const hostile = normalizePhoto(
record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }),
);
assert.notEqual(hostile.urls.view, fileurlLTh);
assert.doesNotMatch(
String(hostile.urls.view),
/tracker|javascript|evil|^http:/,
);
}
// An unexpected storage value falls back to the URLs as given.
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//);
// No large thumbnail path: the size prefix fills in.
const bare = normalizePhoto(
record({ filepathLTh: '', fileurlLTh: '', storage: 'storage3' }),
);
assert.equal(
bare.urls.view,
'https://storage3.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
);
});
test('a 360° photo keeps its equirectangular image; the unmatched position is the fallback', () => {
const photo = normalizePhoto(
record({
projection: 'SPHERE',
fieldOfView: '360',
matchLat: '0',
matchLng: '0',
storage: 'storage13',
filepathProc: '2022/10/6/wrapped_proc/6187609_626b8.jpg',
}),
);
assert.equal(photo.isPano, true);
assert.equal(photo.lat, 42.335712);
assert.equal(
photo.urls.pano,
'https://storage13.openstreetcam.org/files/photo/2022/10/6/wrapped_proc/6187609_626b8.jpg',
);
// A sphere without a wrapped image has nothing to project.
assert.equal(
normalizePhoto(record({ projection: 'SPHERE', fieldOfView: '360' })).urls
.pano,
null,
);
});
test('records without an id or a position are dropped; a missing heading is null', () => {
assert.equal(normalizePhoto(null), null);
// 0,0 is a missing fix.
assert.equal(
normalizePhoto(
record({ lat: '0', lng: '0', matchLat: '0', matchLng: '0' }),
),
null,
);
assert.equal(normalizePhoto(record({ lat: '95', matchLat: '95' })), null);
assert.equal(normalizePhoto(record({ id: undefined })), null);
assert.equal(
normalizePhoto(
record({ lat: 'x', lng: 'y', matchLat: undefined, matchLng: undefined }),
),
null,
);
assert.equal(normalizePhoto(record({ heading: '' })).heading, null);
// A device without a compass reads 0 for a whole drive: unknown, not north.
assert.equal(normalizePhoto(record({ heading: '0' })).heading, null);
assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5);
assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null);
});
test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => {
assert.equal(kartaViewTimeMs('2022-03-21'), Date.UTC(2022, 2, 21));
assert.equal(
kartaViewTimeMs('2022-03-21T06:42:59'),
Date.UTC(2022, 2, 21, 6, 42, 59),
);
assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null);
assert.equal(kartaViewTimeMs(''), null);
assert.equal(kartaViewTimeMs(null), null);
const fallback = normalizePhoto(record({ shotDate: null }));
assert.equal(fallback.capturedAt, Date.UTC(2016, 7, 24, 4, 1, 7));
});
test('resized images go through the proxy with a base64url address', () => {
const url = resizedImageUrl(
'https://storage13.openstreetcam.org/files/photo/a?b=c+d',
[1024, 512],
);
const [, encoded] = /\/rs:fit:1024:512\/(.+)$/.exec(url);
assert.match(url, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:/);
assert.doesNotMatch(encoded, /[+/=]/);
const decoded = Buffer.from(
encoded.replace(/-/g, '+').replace(/_/g, '/'),
'base64',
).toString();
assert.equal(
decoded,
'https://storage13.openstreetcam.org/files/photo/a?b=c+d',
);
assert.equal(resizedImageUrl(null, [1, 1]), null);
});
test('a storage value that only contains a storage host name builds no address from it', () => {
for (const storage of ['evil.example/#storage1', 'storage1.evil.example']) {
const photo = normalizePhoto(record({ storage }));
// Not a storage host: the API's own storage URLs serve.
assert.equal(
photo.urls.view,
'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
storage,
);
assert.equal(
photo.urls.full,
'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
storage,
);
}
});
test('a 360° field of view makes a panorama whatever the projection says', () => {
assert.equal(
normalizePhoto(record({ projection: 'PLANE', fieldOfView: '360' })).isPano,
true,
);
assert.equal(
normalizePhoto(record({ projection: 'PLANE', fieldOfView: '359' })).isPano,
false,
);
});
test('a longitude past ±180 is not a position', () => {
assert.equal(normalizePhoto(record({ lng: '181', matchLng: '181' })), null);
assert.equal(normalizePhoto(record({ lng: '-181', matchLng: '-181' })), null);
assert.equal(
normalizePhoto(record({ lng: '180', matchLng: '180' })).lon,
180,
);
});
test('with no processed-photo path the size prefix gives the full photo', () => {
const photo = normalizePhoto(
record({ filepathProc: '', fileurlProc: '', storage: 'storage3' }),
);
assert.equal(
photo.urls.full,
'https://storage3.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
);
// Only the large thumbnail, no path at all: the thumbnail still shows.
const thumbOnly = normalizePhoto(
record({
filepathProc: '',
fileurlProc: '',
filepath: '',
fileurl: '',
storage: 'storage3',
}),
);
assert.equal(thumbOnly.urls.full, null);
assert.match(
thumbOnly.urls.view,
/^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//,
);
});
test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => {
// North along a meridian, 5 m apart, then a repeat of the last position.
const step = 5 / 110_540;
const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step }));
photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place
const metres = (a, b) =>
Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180),
(b.lat - a.lat) * 110_540,
);
assignCourses(photos, metres);
for (const photo of photos)
assert.ok(
Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5,
`${photo.course}`,
);
// Eastward: 90°.
const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 }));
assignCourses(east, metres);
assert.ok(
Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5,
);
// A lone photo has no course; the compass is the fallback, then unknown.
const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres);
assert.equal(lone.course, null);
assert.equal(cameraBearing({ ...lone, heading: 42 }), 42);
assert.equal(cameraBearing({ ...lone, heading: null }), null);
assert.equal(
cameraBearing({ course: 90, heading: 200 }),
90,
'the drive beats the compass',
);
assert.ok(
Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) <
1e-9,
);
});
test('the imagery filter becomes KartaView’s tile query and photo-search params', () => {
// 2026-03-05 23:30 UTC: the cut-off day is the UTC one.
const sinceMs = Date.UTC(2026, 2, 5, 23, 30);
assert.equal(tileFilterQuery(null), '');
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), '');
assert.equal(
tileFilterQuery({ pano: 'pano', sinceMs: null }),
'fieldOfView=360',
);
assert.equal(
tileFilterQuery({ pano: 'flat', sinceMs }),
'fieldOfView=plane&startDate=2026-03-05',
);
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), '');
// The search ignores fieldOfView; its projection filters instead.
assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {});
assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), {
projection: 'SPHERE',
startDate: '2026-03-05',
});
assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), {
projection: 'PLANE',
});
});
test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => {
const photo = normalizePhoto(
record({
shotDate: '2016-08-23 20:17:47.000',
dateAdded: '2026-03-05 00:10:00',
}),
);
assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10));
const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) });
// Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's
// startDate=2026-03-05 keeps it.
assert.equal(
passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')),
true,
);
assert.equal(
passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')),
false,
);
assert.equal(
passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }),
false,
);
assert.equal(
passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }),
true,
);
assert.equal(passesKartaViewFilter(photo, null), true);
assert.equal(
passesKartaViewFilter(
{ ...photo, uploadedAt: null },
since('2020-01-01T00:00:00Z'),
),
false,
'no upload date passes no SINCE',
);
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,459 @@
import {
IMAGE_PROBE_TIMEOUT_MS,
MAX_RESPONSE_BYTES,
KARTAVIEW_API_HOST,
MASK_ATTEMPTS,
MASK_MAX_BYTES,
MASK_RETRY_WAIT_MS,
MASK_SLOW_MESSAGE,
MASK_TIMEOUT_MS,
NEAREST_LIMIT,
NEAREST_SEARCH_ZOOM,
PAGE_CONCURRENCY,
PAGE_TIMEOUT_MS,
PHOTO_API_CONCURRENCY,
PHOTO_API_RETRIES,
PHOTO_API_RETRY_BASE_MS,
PHOTO_API_RETRY_MAX_MS,
PHOTO_API_FIRST_TIMEOUT_MS,
PHOTO_API_TIMEOUT_MS,
TILE_CONCURRENCY,
TILE_TIMEOUT_MS,
photoFilterParams,
} from './policy.js';
/**
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
* `timedOut` when it did not answer in time (the one failure worth asking
* again at once).
*/
export class KartaViewSourceError extends Error {
constructor(
message,
{ status = 0, retryAfterSec = null, timedOut = false } = {},
) {
super(message);
this.name = 'KartaViewSourceError';
this.status = status;
this.retryAfterSec = retryAfterSec;
this.timedOut = timedOut;
}
}
const NO_ANSWER = 'KartaView did not answer in time — try again';
const UNREACHABLE = 'KartaView could not be reached — check the network';
const UNREADABLE = 'KartaView sent an answer that could not be read';
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
/** Resolves after `ms`, or rejects when `signal` aborts first. */
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(done, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
function done() {
signal?.removeEventListener('abort', onAbort);
resolve();
}
});
}
/** Seconds from a Retry-After header, or null. */
function retryAfterSeconds(response) {
const header = response.headers?.get?.('retry-after');
const value = header == null || header === '' ? NaN : Number(header);
return Number.isFinite(value) && value >= 0 ? value : null;
}
/**
* 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;
},
};
}
/**
* KartaView's public API, browser-side and keyless (it allows any origin).
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
* at most three, photo lookups two and sequence pages one, so a lookup never
* waits behind slow tiles or pages. The photo API answers 429 to more than
* about three requests at once; a 429 (and a lookup that timed out) is
* retried.
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options]
*/
export function createKartaViewSource({
fetchImpl = (...args) => globalThis.fetch(...args),
apiHost = KARTAVIEW_API_HOST,
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
maskRetryMs = MASK_RETRY_WAIT_MS,
} = {}) {
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
const tileLimiter = createLimiter(TILE_CONCURRENCY);
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
function getJson(url, { signal, timeoutMs }) {
return fetchBody(url, { signal, timeoutMs, as: 'json' });
}
/**
* GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON
* or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the
* timeout.
*/
async function fetchBody(
url,
{ signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES },
) {
const controller = new AbortController();
const onAbort = () => controller.abort(signal.reason);
if (signal?.aborted) throw signal.reason;
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(
() =>
controller.abort(
new KartaViewSourceError(NO_ANSWER, { timedOut: true }),
),
timeoutMs,
);
let response = null;
let read = false;
try {
response = await fetchImpl(url, { signal: controller.signal });
if (response.status === 429)
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
status: 429,
retryAfterSec: retryAfterSeconds(response) ?? 2,
});
if (!response.ok)
throw new KartaViewSourceError(
`KartaView answered HTTP ${response.status}`,
{ status: response.status },
);
const length = Number(response.headers?.get?.('content-length'));
const tooMuch = () =>
new KartaViewSourceError('KartaView sent too much data', {
status: response.status,
});
if (length > maxBytes) throw tooMuch();
// KartaView answers chunked, without a length: count while reading.
const reader = response.body?.getReader?.();
if (!reader) {
read = true;
try {
if (as === 'bytes')
return new Uint8Array(await response.arrayBuffer());
return await response.json();
} catch (error) {
if (controller.signal.aborted) throw error;
throw new KartaViewSourceError(UNREADABLE, {
status: response.status,
});
}
}
read = true;
const chunks = [];
let bytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
reader.cancel().catch(() => {});
throw tooMuch();
}
chunks.push(value);
}
const body = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
if (as === 'bytes') return body;
try {
return JSON.parse(new TextDecoder().decode(body));
} catch {
throw new KartaViewSourceError(UNREADABLE, {
status: response.status,
});
}
} catch (error) {
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
if (error instanceof KartaViewSourceError) throw error;
// fetch rejects on no network, a blocked request or a refused CORS check.
throw new KartaViewSourceError(UNREACHABLE);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
// An answer turned away unread (429, an error, too long) still holds
// its connection, one of the six the browser has: let it go.
if (response && !read) {
controller.abort();
if (response.body && !response.body.locked)
response.body.cancel?.().catch?.(() => {});
}
}
}
/**
* A photo-API call through `gate`, retrying 429s with backoff and, once, a
* call that timed out.
*/
async function photoApi(
path,
params,
{ signal } = {},
{
gate = limiter,
timeoutMs = PHOTO_API_TIMEOUT_MS,
firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS,
} = {},
) {
const query = params ? `?${new URLSearchParams(params)}` : '';
const url = `${apiHost}${path}${query}`;
let timeouts = 0;
for (let attempt = 0; ; attempt++) {
try {
const payload = await gate.run(
// A slow first answer is usually quick when asked again.
() =>
getJson(url, {
signal,
timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs,
}),
{ signal },
);
const code = Number(payload?.status?.apiCode);
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
if (Number.isFinite(code) && (code < 600 || code > 603))
throw new KartaViewSourceError(
`KartaView refused the request (code ${code})`,
{ status: Number(payload?.status?.httpCode) || 0 },
);
return payload?.result ?? {};
} catch (error) {
if (signal?.aborted) throw error;
const timedOut = error?.timedOut === true;
if (timedOut && timeouts++ < 1) continue;
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
if (error?.status === 429)
throw new KartaViewSourceError(
'KartaView is rate-limiting requests — try again in a moment',
{ status: 429, retryAfterSec: error.retryAfterSec },
);
throw error;
}
await wait(
Math.min(
PHOTO_API_RETRY_MAX_MS,
Math.max(
retryBaseMs * 2 ** attempt,
(error.retryAfterSec ?? 0) * 1000,
),
),
signal,
);
}
}
}
return {
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
async getTile({ x, y, z }, { signal } = {}) {
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`;
const once = () =>
tileLimiter.run(
() => getJson(url, { signal, timeoutMs: TILE_TIMEOUT_MS }),
{ signal },
);
try {
return await once();
} catch (error) {
// A cold dense tile can outlast the timeout; KartaView has it ready
// by the second ask.
if (signal?.aborted || error?.timedOut !== true) throw error;
return once();
}
},
/**
* A filtered coverage tile as PNG bytes, `query` from tileFilterQuery
* (KartaView filters only its PNG tiles). It draws a filtered tile on
* demand; a dense one can outlast its own 60 s (504) and still be drawn,
* so a timeout or a 5xx is asked again a few seconds later.
*/
async getTileMask({ x, y, z }, query, { signal } = {}) {
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`;
for (let attempt = 1; ; attempt++) {
try {
// Outside the gate between tries: a wait holds no connection.
return await tileLimiter.run(
() =>
fetchBody(url, {
signal,
timeoutMs: MASK_TIMEOUT_MS,
as: 'bytes',
maxBytes: MASK_MAX_BYTES,
}),
{ signal },
);
} catch (error) {
if (signal?.aborted) throw error;
const drawing =
error?.timedOut === true || [502, 503, 504].includes(error?.status);
if (!drawing) throw error;
if (attempt >= MASK_ATTEMPTS)
throw new KartaViewSourceError(MASK_SLOW_MESSAGE, {
timedOut: true,
});
await wait(maskRetryMs, signal);
}
}
},
/**
* Whether a photo file is there: true, false when it is gone (archived
* photos answer 409, and a HEAD says 200 regardless), or null when the
* storage did not say (timeout, network, 5xx). It GETs and stops at the
* headers.
*/
async probeImage(url, { signal } = {}) {
if (!url) return false;
const controller = new AbortController();
const onAbort = () => controller.abort(signal.reason);
if (signal?.aborted) throw signal.reason;
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(
() => controller.abort(),
IMAGE_PROBE_TIMEOUT_MS,
);
try {
const response = await fetchImpl(url, { signal: controller.signal });
if (response.ok) return true;
return GONE_STATUSES.includes(response.status) ? false : null;
} catch {
if (signal?.aborted) throw signal.reason;
return null;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
// The body is not needed: drop it.
controller.abort();
}
},
/**
* Photo records around a point, nearest first; with a resolved imagery
* `filter`, only those of its projection uploaded since its cut-off.
*/
async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) {
const result = await photoApi(
'/2.0/photo/',
{
lat: lat.toFixed(7),
lng: lon.toFixed(7),
zoomLevel: String(NEAREST_SEARCH_ZOOM),
itemsPerPage: String(NEAREST_LIMIT),
...photoFilterParams(filter),
},
{ signal },
);
return Array.isArray(result.data) ? result.data : [];
},
/** One photo record. */
async getPhoto(photoId, { signal } = {}) {
const result = await photoApi(
`/2.0/photo/${encodeURIComponent(photoId)}`,
null,
{ signal },
);
if (!result.data || typeof result.data !== 'object')
throw new KartaViewSourceError('KartaView has no such photo');
return result.data;
},
/** How many photos a sequence has, or null if KartaView does not say. */
async getSequenceCount(sequenceId, { signal } = {}) {
const result = await photoApi(
`/2.0/sequence/${encodeURIComponent(sequenceId)}`,
null,
{ signal },
);
const count = Number(result.data?.countActivePhotos);
return Number.isFinite(count) && count > 0 ? count : null;
},
/** A page of a sequence's photos in capture order (1-based `page`). */
async getSequencePhotos(
sequenceId,
{ page, itemsPerPage },
{ signal } = {},
) {
const result = await photoApi(
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
{ page: String(page), itemsPerPage: String(itemsPerPage) },
{ signal },
{
gate: pageLimiter,
timeoutMs: PAGE_TIMEOUT_MS,
firstTimeoutMs: PAGE_TIMEOUT_MS,
},
);
return {
photos: Array.isArray(result.data) ? result.data : [],
hasMore: result.hasMoreData === true,
};
},
};
}
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more