mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge upstream/main: cockpit emergency, GNSS integrity and GeoJSON import meet i18n
Upstream advanced past the KartaView merge: transponder-emergency HUD
wording (formatCockpitEmergency in cockpitMath), the GNSS integrity
layer joined the Events group, and a GeoJSON/KML/KMZ/GPX file-import
control landed - all in regions this branch localizes.
- cockpitMath: upstream's formatCockpitEmergency kept alongside the
branch's formatter-injected Context scope (module stays i18n-free;
squawk/ADS-B codes and the broadcast label are data).
- cockpitInstruments: the emergency HUD line composed with t()
placeholders; fleet/feedState stay translated, the 'emergency' class
toggle kept.
- layerPanel: upstream's gnss-integrity id with msg('Events').
- package-boundaries: both sides' entries unioned.
- New upstream UI strings marked and translated, as the branch's
unmarked-string detector requires: the geo-import control's markup
(data-i18n-title/-aria-label/text), its list titles, hints and
summarize() lines (t/tn with placeholders), the aircraft emergency
labels (msg data), and the GNSS legend blurb (t with placeholders).
- es/ru catalogs: 26 new keys each (2019 messages).
npm run i18n: complete, no unmarked strings. npm test: 6739/6741 (the
one failure is the pre-existing environmental OPENAI_API_KEY leak in
devFreshDotenv). Build passes.
This commit is contained in:
commit
0211e6e877
120 files changed
+12921
-254
No files matched your search
@@ -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: [{
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -83,6 +83,27 @@ 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The English Context scope line. The cockpit passes its own formatter so the
|
||||
* page shows it in the active language; this module stays i18n-free.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { msg } from '../i18n/index.js';
|
||||
|
||||
/** @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: msg('General emergency'), severity: 'emergency' },
|
||||
unlawful: { label: msg('Unlawful interference'), severity: 'emergency' },
|
||||
nordo: { label: msg('Radio failure'), severity: 'emergency' },
|
||||
downed: { label: msg('Downed aircraft'), severity: 'emergency' },
|
||||
minfuel: { label: msg('Minimum fuel'), severity: 'priority' },
|
||||
lifeguard: { label: msg('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';
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'adsblol',
|
||||
html:
|
||||
'Military flights, aircraft traces & bounded regional flight fallback: ' +
|
||||
'Military flights, aircraft traces, bounded regional flight fallback & GNSS navigation integrity: ' +
|
||||
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
|
||||
'(ODbL 1.0)',
|
||||
},
|
||||
|
||||
+28
-16
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -163,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -610,6 +610,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',
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -993,11 +993,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(
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -2123,5 +2123,39 @@
|
||||
"fire confidence|low": "baja",
|
||||
"age|<1h": "<1 h",
|
||||
"age|{count}h": "{count} h",
|
||||
"age|{count}d": "{count} d"
|
||||
"age|{count}d": "{count} d",
|
||||
"General emergency": "Emergencia general",
|
||||
"Unlawful interference": "Interferencia ilícita",
|
||||
"Radio failure": "Fallo de radio",
|
||||
"Downed aircraft": "Aeronave derribada",
|
||||
"Minimum fuel": "Combustible mínimo",
|
||||
"Lifeguard (medical priority)": "Lifeguard (prioridad médica)",
|
||||
"Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {minAircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Puntuación de baja precisión = 100 × máx(0, malas − 1) / total: se descuenta una aeronave, así que no es la proporción bruta. Las celdas necesitan {minAircraft} aeronaves durante {minutes} minutos. Las anomalías de integridad de navegación no constituyen una detección de jamming o spoofing.",
|
||||
"{fleet} · {emergency} · {feedState}": "{fleet} · {emergency} · {feedState}",
|
||||
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "No se pudieron refrescar las alturas del terreno. Prueba de nuevo con el archivo cuando el mapa haya cargado.",
|
||||
"{count} map objects": {
|
||||
"one": "{count} objeto de mapa",
|
||||
"many": "{count} objetos de mapa",
|
||||
"other": "{count} objetos de mapa"
|
||||
},
|
||||
"{name} — {format}, {objects}, drawn with its own styles": "{name} — {format}, {objects}, dibujado con sus propios estilos",
|
||||
"{name} — {format}, {objects}": "{name} — {format}, {objects}",
|
||||
"Hide {name}": "Ocultar {name}",
|
||||
"Show {name}": "Mostrar {name}",
|
||||
"Fly to {name}": "Volar a {name}",
|
||||
"Remove {name}": "Quitar {name}",
|
||||
"At most {max} files at once — remove one first.": "Como máximo {max} archivos a la vez — quita uno primero.",
|
||||
"Reading {file}…": "Leyendo {file}…",
|
||||
"Added {count} files.": {
|
||||
"one": "Se añadió {count} archivo.",
|
||||
"many": "Se añadieron {count} archivos.",
|
||||
"other": "Se añadieron {count} archivos."
|
||||
},
|
||||
"{name}: {error}": "{name}: {error}",
|
||||
"{count} files could not be added ({name}: {error})": "No se pudieron añadir {count} archivos ({name}: {error})",
|
||||
"Some KML content was left out.": "Parte del contenido KML se quedó fuera.",
|
||||
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Muestra tus propios archivos GeoJSON, KML, KMZ o GPX sobre el globo (o suéltalos sobre él). Los bytes del archivo se quedan en este dispositivo; las superposiciones pueden aparecer en capturas del mapa compartidas por voz.",
|
||||
"Import": "Importar",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Elige archivos GeoJSON, KML, KMZ o GPX",
|
||||
"Imported files": "Archivos importados"
|
||||
}
|
||||
@@ -2156,5 +2156,41 @@
|
||||
"fire confidence|low": "низкая",
|
||||
"age|<1h": "<1 ч",
|
||||
"age|{count}h": "{count} ч",
|
||||
"age|{count}d": "{count} д"
|
||||
"age|{count}d": "{count} д",
|
||||
"General emergency": "Общая чрезвычайная ситуация",
|
||||
"Unlawful interference": "Незаконное вмешательство",
|
||||
"Radio failure": "Отказ радиосвязи",
|
||||
"Downed aircraft": "Упавшее воздушное судно",
|
||||
"Minimum fuel": "Минимум топлива",
|
||||
"Lifeguard (medical priority)": "Lifeguard (медицинский приоритет)",
|
||||
"Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {minAircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Оценка низкой точности = 100 × макс(0, плохих − 1) / всего: один борт вычитается, поэтому это не сырая доля. Ячейкам нужны {minAircraft} бортов в течение {minutes} минут. Аномалии навигационной целостности — не обнаружение глушения или спуфинга.",
|
||||
"{fleet} · {emergency} · {feedState}": "{fleet} · {emergency} · {feedState}",
|
||||
"Ground heights could not be refreshed. Try the file again when the map has loaded.": "Не удалось обновить высоты рельефа. Повторите загрузку файла, когда карта прогрузится.",
|
||||
"{count} map objects": {
|
||||
"one": "{count} объект карты",
|
||||
"few": "{count} объекта карты",
|
||||
"many": "{count} объектов карты",
|
||||
"other": "{count} объекта карты"
|
||||
},
|
||||
"{name} — {format}, {objects}, drawn with its own styles": "{name} — {format}, {objects}, отрисован собственными стилями",
|
||||
"{name} — {format}, {objects}": "{name} — {format}, {objects}",
|
||||
"Hide {name}": "Скрыть {name}",
|
||||
"Show {name}": "Показать {name}",
|
||||
"Fly to {name}": "Перелететь к {name}",
|
||||
"Remove {name}": "Убрать {name}",
|
||||
"At most {max} files at once — remove one first.": "Не больше {max} файлов одновременно — сначала уберите один.",
|
||||
"Reading {file}…": "Чтение {file}…",
|
||||
"Added {count} files.": {
|
||||
"one": "Добавлен {count} файл.",
|
||||
"few": "Добавлено {count} файла.",
|
||||
"many": "Добавлено {count} файлов.",
|
||||
"other": "Добавлено {count} файла."
|
||||
},
|
||||
"{name}: {error}": "{name}: {error}",
|
||||
"{count} files could not be added ({name}: {error})": "Не удалось добавить {count} файлов ({name}: {error})",
|
||||
"Some KML content was left out.": "Часть содержимого KML была пропущена.",
|
||||
"Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Показывайте свои файлы GeoJSON, KML, KMZ или GPX на глобусе (или перетащите их на него). Байты файла остаются на этом устройстве; наложения могут попасть в снимки карты, которыми делятся голосом.",
|
||||
"Import": "Импорт",
|
||||
"Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX",
|
||||
"Imported files": "Импортированные файлы"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
ERROR_BACKOFF_INTERVAL,
|
||||
} from './recordPolicy.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { EMERGENCY_TINT_CSS } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
@@ -14,6 +15,11 @@ export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
export const MIL_TINT = Cesium.Color.fromCssColorString('#FFB800');
|
||||
|
||||
/** Red tint for aircraft broadcasting an emergency (7500/7600/7700 or ADS-B status). */
|
||||
|
||||
export const EMERGENCY_TINT =
|
||||
Cesium.Color.fromCssColorString(EMERGENCY_TINT_CSS);
|
||||
|
||||
// --- Ground traffic (owner reversal 2026-07-03: "absolutely we should see planes
|
||||
// taxiing and landing") -----------------------------------------------------------
|
||||
// Present-but-grounded planes are RENDERED instead of being skipped: same class
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
flightState,
|
||||
@@ -167,6 +168,10 @@ export function createQueries({
|
||||
flightState.records.missingPolls.get(icao24) ||
|
||||
flightState.feed._backoff,
|
||||
),
|
||||
// This poll's transponder broadcast (not sticky; see records.js).
|
||||
squawk: info?.squawk ?? null,
|
||||
emergency: info?.emergency ? { ...info.emergency } : null,
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
airline: info?.airline ?? null,
|
||||
// CLASS label follows the TR-3B conversion so every downstream card
|
||||
// (cockpit, Contacts, analyst) agrees with the triangle on screen.
|
||||
@@ -219,7 +224,10 @@ export function createQueries({
|
||||
* lon: number|null, altitudeM: number|null, speedMps: number|null,
|
||||
* heading: number|null, verticalRateMps: number|null, onGround: boolean,
|
||||
* military: boolean, aircraftClass: string|null, originCountry: string|null,
|
||||
* operator: string|null, routeOrigin: string|null, routeDestination: string|null}}
|
||||
* operator: string|null, squawk: string|null, emergency: boolean,
|
||||
* emergencyKind: string|null, emergencySource: 'squawk'|'ads-b'|null,
|
||||
* emergencyBroadcast: string|null, lastContactAtMs: number|null, routeOrigin: string|null,
|
||||
* routeDestination: string|null}}
|
||||
*/
|
||||
|
||||
function mapAnalystRecord(
|
||||
@@ -253,6 +261,20 @@ export function createQueries({
|
||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||
originCountry: text(info?.originCountry),
|
||||
operator: text(info?.airline),
|
||||
squawk: text(info?.squawk),
|
||||
// True while this poll broadcast an emergency (7500/7600/7700 or the
|
||||
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
|
||||
// without the flag.
|
||||
emergency: info?.emergency?.severity === 'emergency',
|
||||
emergencyKind: text(info?.emergency?.kind),
|
||||
// Provenance, so a summary can say "broadcasting squawk 7700" rather
|
||||
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
|
||||
// when the Mode A code names the status, 'ads-b' for the readsb field.
|
||||
emergencySource: text(info?.emergency?.source),
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
// Latest known general aircraft-message contact, retained if a poll
|
||||
// has no time. This is not the receipt time of the current status.
|
||||
lastContactAtMs: num(info?.lastContactEpochMs),
|
||||
routeOrigin: routeOk ? text(info?.route?.origin?.code) : null,
|
||||
routeDestination: routeOk ? text(info?.route?.destination?.code) : null,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
pickRenderAltitudeM,
|
||||
} from '../../data/renderAltitude.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
approxDistanceKm,
|
||||
GROUND_FLOOR_CLAMP_RADIUS_KM,
|
||||
@@ -226,6 +227,22 @@ export class FlightRecords {
|
||||
// like callsign so a transient blank row doesn't blank the field.
|
||||
originCountry:
|
||||
stickyText(origin_country, prevMeta?.originCountry) || null,
|
||||
// Per poll, NOT sticky: an emergency code clears when the crew resets
|
||||
// it, so a blank squawk must not hold a stale 7700 on screen.
|
||||
squawk: observation.squawk ?? null,
|
||||
// One source-switch omission may retain an ADS-B status, while an
|
||||
// explicit clear or changed squawk always takes effect immediately.
|
||||
emergency:
|
||||
observation.emergency === undefined &&
|
||||
observation.squawk === prevMeta?.squawk &&
|
||||
prevMeta?.emergency?.source === 'ads-b' &&
|
||||
(prevMeta?.missingEmergencyPolls ?? 0) < 1
|
||||
? prevMeta.emergency
|
||||
: aircraftEmergency(observation),
|
||||
missingEmergencyPolls:
|
||||
observation.emergency === undefined
|
||||
? (prevMeta?.missingEmergencyPolls ?? 0) + 1
|
||||
: 0,
|
||||
// OpenSky distinguishes the last position epoch from the last
|
||||
// transponder message. The fleet coast horizon uses this actual
|
||||
// contact time so a temporarily old position does not hard-freeze
|
||||
|
||||
@@ -106,3 +106,13 @@ test('ground transitions preserve last render height while a surface cell is col
|
||||
assert.equal(next.meta.renderAltitudeM, 1040);
|
||||
assert.equal(next.meta.wasAirborne, true);
|
||||
});
|
||||
|
||||
test('civil emergency status follows the latest poll instead of sticking', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation({ squawk: '7700' }), view());
|
||||
assert.equal(first.meta.squawk, '7700');
|
||||
assert.equal(first.meta.emergency.kind, 'general');
|
||||
const next = store.receive(observation({ squawk: '2000' }), view());
|
||||
assert.equal(next.meta.squawk, '2000');
|
||||
assert.equal(next.meta.emergency, null);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from './modelSpec.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
EMERGENCY_TINT,
|
||||
GROUND_SCALE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
@@ -54,13 +56,34 @@ export function createRendering({
|
||||
const { applyAircraftBillboardTreatment, applyAircraftModelTreatment } =
|
||||
services.recession;
|
||||
|
||||
/** Fleet (untracked) billboard tint: amber for known-military, white otherwise.
|
||||
/** True while this contact's latest poll broadcast an emergency code. */
|
||||
|
||||
function _isEmergency(icao24, info = flightState.records.data.get(icao24)) {
|
||||
return isEmergency(info?.emergency);
|
||||
}
|
||||
|
||||
/** Fleet (untracked) billboard tint: red while broadcasting an emergency,
|
||||
* amber for known-military, white otherwise.
|
||||
* Ground traffic gets NO special tint (owner verdict 2026-07-03 field test). */
|
||||
|
||||
function _fleetBillboardColor(icao24) {
|
||||
function _fleetBillboardColor(
|
||||
icao24,
|
||||
info = flightState.records.data.get(icao24),
|
||||
) {
|
||||
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
}
|
||||
|
||||
/** Cockpit far-contact dot tint: the emergency red outranks the category tint. */
|
||||
|
||||
function _cockpitContactColor(
|
||||
icao24,
|
||||
info = flightState.records.data.get(icao24),
|
||||
) {
|
||||
if (_isEmergency(icao24, info)) return EMERGENCY_TINT;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR;
|
||||
}
|
||||
|
||||
/** Fleet billboard scale: per-class scale, ×GROUND_SCALE while grounded. */
|
||||
|
||||
function _fleetBillboardScale(icao24, klass) {
|
||||
@@ -129,9 +152,7 @@ export function createRendering({
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.scale = limbScale;
|
||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||
bb.color = (
|
||||
isMilitaryIcao(icao24) ? MIL_TINT : COCKPIT_CIVILIAN_COLOR
|
||||
).withAlpha(freshnessAlpha);
|
||||
bb.color = _cockpitContactColor(icao24).withAlpha(freshnessAlpha);
|
||||
bb.rotation = 0;
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +183,7 @@ export function createRendering({
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN;
|
||||
return isMilitaryIcao(icao24) ? MIL_TINT : Cesium.Color.WHITE;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
@@ -1009,10 +1030,8 @@ export function createRendering({
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const layerBaseColor =
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? isMilitaryIcao(icao24)
|
||||
? MIL_TINT
|
||||
: COCKPIT_CIVILIAN_COLOR
|
||||
: _fleetBillboardColor(icao24);
|
||||
? _cockpitContactColor(icao24, info)
|
||||
: _fleetBillboardColor(icao24, info);
|
||||
const baseColor = layerBaseColor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
GNSS_LEVEL_COLORS,
|
||||
GNSS_MIN_AIRCRAFT,
|
||||
GNSS_WINDOW_MS,
|
||||
accumulateGnssObservations,
|
||||
binGnssCells,
|
||||
gnssProvenance,
|
||||
pruneGnssObservations,
|
||||
} from './records.js';
|
||||
import { t } from '../../i18n/index.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: t(
|
||||
'Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {minAircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.',
|
||||
{
|
||||
minAircraft: GNSS_MIN_AIRCRAFT,
|
||||
minutes: provenance.window.minutes,
|
||||
},
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _cells.length,
|
||||
levels: Object.fromEntries(
|
||||
['low', 'medium', 'high'].map((level) => [
|
||||
level,
|
||||
_cells.filter((cell) => cell.level === level).length,
|
||||
]),
|
||||
),
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
retryInSec: _retryAt
|
||||
? Math.max(0, Math.ceil((_retryAt - now()) / 1000))
|
||||
: null,
|
||||
stale: _stale,
|
||||
provenance,
|
||||
coverage: _coverage,
|
||||
// Load-bearing: without an explicit boolean, layerFeedState reads the
|
||||
// adsb.lol source name as a flights fallback (src/data/feedState.js).
|
||||
fallback: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
|
||||
import { LayerLifecycle } from '../../data/lifecycle.js';
|
||||
import { layerFeedState } from '../../data/feedState.js';
|
||||
|
||||
const row = (hex, lat, degraded) => ({
|
||||
hex,
|
||||
lat,
|
||||
lon: 30.2,
|
||||
nic: degraded ? 0 : 8,
|
||||
nacp: degraded ? 0 : 10,
|
||||
gpsLost: false,
|
||||
degraded,
|
||||
});
|
||||
|
||||
function harness(
|
||||
getSnapshot,
|
||||
{
|
||||
anchor: initialAnchor = { latitude: 50, longitude: 30 },
|
||||
initialize = true,
|
||||
} = {},
|
||||
) {
|
||||
let anchor = initialAnchor;
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
|
||||
scene: {
|
||||
globe: { show: true },
|
||||
groundPrimitives: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
value.destroy();
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
let clock = 1_000;
|
||||
const anchors = [];
|
||||
const layer = createGnssIntegrityLayer({
|
||||
// The real source refuses an undated snapshot; stubs default to a fresh one.
|
||||
source: {
|
||||
async getSnapshot(...args) {
|
||||
const snapshot = await getSnapshot(...args);
|
||||
return { ageMs: 0, ...snapshot };
|
||||
},
|
||||
},
|
||||
viewAnchor: () => {
|
||||
anchors.push(anchor);
|
||||
return anchor;
|
||||
},
|
||||
now: () => clock,
|
||||
monotonicNow: () => clock,
|
||||
});
|
||||
if (initialize) layer.init(viewer);
|
||||
return {
|
||||
layer,
|
||||
sources,
|
||||
anchors,
|
||||
viewer,
|
||||
advance(ms) {
|
||||
clock += ms;
|
||||
},
|
||||
now: () => clock,
|
||||
setAnchor(value) {
|
||||
anchor = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const MIN = 60_000;
|
||||
const threeHealthy = () => [
|
||||
row('a1', 1.1, false),
|
||||
row('a2', 1.2, false),
|
||||
row('a3', 1.3, false),
|
||||
];
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
|
||||
const anchor = defaultGnssViewAnchor({
|
||||
scene: {
|
||||
canvas: { clientWidth: 1000, clientHeight: 800 },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
camera: {
|
||||
pickEllipsoid(pixel) {
|
||||
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
|
||||
return Cesium.Cartesian3.fromDegrees(-97, 30);
|
||||
},
|
||||
computeViewRectangle: () => {
|
||||
throw new Error('centre hit must not compute a rectangle');
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(Math.abs(anchor.latitude - 30) < 1e-8);
|
||||
assert.ok(Math.abs(anchor.longitude + 97) < 1e-8);
|
||||
});
|
||||
|
||||
test('pan and zoom refresh through the manager without toggling; nearby changes are coalesced', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
let calls = 0;
|
||||
const h = harness(
|
||||
async () => {
|
||||
calls++;
|
||||
return { rows: threeHealthy() };
|
||||
},
|
||||
{ initialize: false },
|
||||
);
|
||||
const manager = new LayerLifecycle(h.viewer);
|
||||
manager.register(h.layer);
|
||||
h.layer.attachDataManager(manager);
|
||||
const changes = [];
|
||||
manager.subscribe((change) => changes.push(change.type));
|
||||
await manager.setEnabled(h.layer.id, true);
|
||||
h.setAnchor({ latitude: 54, longitude: 19 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.setAnchor({ latitude: 55, longitude: 20 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
assert.equal(calls, 2, 'one initial read and one read for the latest view');
|
||||
assert.deepEqual(h.anchors.at(-1), { latitude: 55, longitude: 20 });
|
||||
assert.ok(changes.includes('refresh-transition'));
|
||||
assert.ok(changes.includes('refresh'));
|
||||
assert.equal(manager.isEnabled(h.layer.id), true);
|
||||
assert.deepEqual(h.layer.getStats().coverage, {
|
||||
kind: 'regional',
|
||||
latitude: 55,
|
||||
longitude: 20,
|
||||
radiusNm: 250,
|
||||
});
|
||||
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
|
||||
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(5000);
|
||||
t.mock.timers.tick(5000);
|
||||
await flush();
|
||||
assert.equal(calls, 2, 'nearby views reuse the same query region');
|
||||
await manager.destroyAll();
|
||||
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const calls = [];
|
||||
let resolvePending;
|
||||
const h = harness((anchor) => {
|
||||
calls.push(anchor);
|
||||
if (calls.length === 2)
|
||||
return new Promise((resolve) => {
|
||||
resolvePending = resolve;
|
||||
});
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.setAnchor({ latitude: 54, longitude: 19 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
h.setAnchor({ latitude: 35, longitude: 140 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
assert.equal(calls.length, 2, 'only one camera request in flight');
|
||||
resolvePending({ rows: threeHealthy() });
|
||||
await flush();
|
||||
h.advance(600);
|
||||
t.mock.timers.tick(600);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3);
|
||||
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
|
||||
h.setAnchor({ latitude: 10, longitude: 10 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.layer.disable();
|
||||
h.advance(5000);
|
||||
t.mock.timers.tick(5000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3, 'disable drains the camera timer');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('the layer requires a snapshot source and cannot be initialized twice', () => {
|
||||
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
|
||||
const { layer } = harness(async () => ({ rows: [] }));
|
||||
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
|
||||
});
|
||||
|
||||
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
|
||||
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
|
||||
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
|
||||
assert.ok(signal instanceof AbortSignal);
|
||||
return {
|
||||
rows: [
|
||||
row('a1', 50.1, true),
|
||||
row('a2', 50.1, true),
|
||||
row('a3', 50.2, false),
|
||||
row('a4', 50.3, false),
|
||||
row('b1', 55.1, false),
|
||||
],
|
||||
stale: false,
|
||||
};
|
||||
});
|
||||
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
|
||||
assert.equal(anchors.length, 0);
|
||||
layer.enable();
|
||||
assert.equal(sources[0].show, true);
|
||||
assert.equal(await layer.update(), true);
|
||||
const entities = sources[0].get(0).geometryInstances;
|
||||
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
|
||||
assert.equal(entities[0].id, 'gnss-integrity:280:420');
|
||||
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
|
||||
assert.equal(sources[0].get(0).allowPicking, false);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.count, 1);
|
||||
assert.equal(stats.error, null);
|
||||
assert.equal(stats.fallback, false);
|
||||
const { legend } = layer.getRowControls();
|
||||
assert.deepEqual(
|
||||
legend.map(({ count }) => count),
|
||||
[1, 0, 0],
|
||||
);
|
||||
assert.match(legend[0].blurb, /not a detection/);
|
||||
assert.match(legend[0].blurb, /need 3 aircraft/);
|
||||
});
|
||||
|
||||
test('the visible layer leads with low navigation accuracy and exposes its provenance', async () => {
|
||||
const { layer } = harness(async () => ({ rows: [], stale: false }));
|
||||
assert.equal(
|
||||
layer.id,
|
||||
'gnss-integrity',
|
||||
'the unpublished id describes the measured state',
|
||||
);
|
||||
assert.equal(layer.name, 'GNSS Integrity');
|
||||
assert.doesNotMatch(layer.name, /interference|jamming/i);
|
||||
const { legend } = layer.getRowControls();
|
||||
assert.deepEqual(
|
||||
legend.map(({ label }) => label),
|
||||
[
|
||||
'Low-accuracy score > 10%',
|
||||
'Low-accuracy score 2–10%',
|
||||
'Low-accuracy score < 2%',
|
||||
],
|
||||
);
|
||||
const { blurb } = legend[0];
|
||||
assert.match(blurb, /one aircraft is discounted/);
|
||||
assert.match(blurb, /not the raw share/);
|
||||
assert.match(blurb, /not a detection/);
|
||||
assert.equal(layer.provenance.classifier.id, 'gev-nic-nacp-v2');
|
||||
assert.equal(layer.provenance.classifier.definedBy, 'GEV');
|
||||
assert.equal(layer.provenance.classifier.validated, false);
|
||||
assert.equal(layer.provenance.aggregation.source, 'https://gpsjam.org/faq');
|
||||
assert.deepEqual(layer.provenance.window, {
|
||||
minutes: 30,
|
||||
scope: 'visited-view',
|
||||
cellDeg: 0.5,
|
||||
minAircraft: 3,
|
||||
definedBy: 'GEV',
|
||||
});
|
||||
assert.equal(layer.provenance.interpretation.validated, false);
|
||||
assert.equal(layer.getStats().provenance, layer.provenance);
|
||||
});
|
||||
|
||||
test('a failed refresh keeps the last cells and reports the error', async () => {
|
||||
let fail = false;
|
||||
const { layer, sources } = harness(async () => {
|
||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||
return {
|
||||
rows: [
|
||||
row('a1', 1.1, false),
|
||||
row('a2', 1.2, false),
|
||||
row('a3', 1.3, false),
|
||||
],
|
||||
stale: true,
|
||||
};
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().stale, true);
|
||||
fail = true;
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
||||
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
});
|
||||
|
||||
test('observations expire with the window, and disable aborts the request in flight', async () => {
|
||||
let pending;
|
||||
const { layer, sources, advance } = harness(
|
||||
(anchor, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
pending = { resolve };
|
||||
signal.addEventListener('abort', () => reject(signal.reason));
|
||||
}),
|
||||
);
|
||||
layer.enable();
|
||||
let result = layer.update();
|
||||
pending.resolve({
|
||||
rows: [row('a1', 1.1, false), row('a2', 1.2, false), row('a3', 1.3, false)],
|
||||
});
|
||||
assert.equal(await result, true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
|
||||
advance(31 * 60_000);
|
||||
result = layer.update();
|
||||
pending.resolve({ rows: [] });
|
||||
assert.equal(await result, true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
|
||||
result = layer.update();
|
||||
layer.disable();
|
||||
assert.equal(await result, false);
|
||||
assert.equal(sources[0].show, false);
|
||||
});
|
||||
|
||||
test('no view anchor means no request, and destroy releases the data source', async () => {
|
||||
let calls = 0;
|
||||
const { layer, sources } = harness(
|
||||
async () => {
|
||||
calls += 1;
|
||||
return { rows: [] };
|
||||
},
|
||||
{ anchor: null },
|
||||
);
|
||||
layer.enable();
|
||||
assert.equal(
|
||||
await layer.update(),
|
||||
true,
|
||||
'a settling camera is not a failure',
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
layer.destroy();
|
||||
assert.equal(sources.length, 0);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
|
||||
// The proxy keeps answering with the snapshot it observed at t=1000.
|
||||
const { layer, sources, advance, now } = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
ageMs: now() - 1_000,
|
||||
stale: true,
|
||||
}));
|
||||
layer.enable();
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(29 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(2 * MIN);
|
||||
assert.equal(
|
||||
await layer.update(),
|
||||
true,
|
||||
'the replay is not an error, only no evidence',
|
||||
);
|
||||
assert.equal(
|
||||
layer.getStats().count,
|
||||
0,
|
||||
'the replay did not restart the window',
|
||||
);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('cells expire during prolonged fetch failures', async () => {
|
||||
let fail = false;
|
||||
const { layer, sources, advance } = harness(async () => {
|
||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
fail = true;
|
||||
advance(20 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1, 'still inside the window');
|
||||
advance(11 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
|
||||
});
|
||||
|
||||
test('cells expire while no view anchor is available', async () => {
|
||||
let calls = 0;
|
||||
const { layer, sources, advance, setAnchor } = harness(async () => {
|
||||
calls += 1;
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
setAnchor(null);
|
||||
advance(20 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
advance(11 * MIN);
|
||||
assert.equal(await layer.update(), true);
|
||||
assert.equal(calls, 1, 'no anchor means no request');
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
});
|
||||
|
||||
test('re-enabling after a long disable does not show cells past the window', async () => {
|
||||
const { layer, sources, advance } = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
}));
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
||||
layer.disable();
|
||||
advance(31 * MIN);
|
||||
layer.enable();
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
||||
});
|
||||
|
||||
const threeCells = () => ({
|
||||
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
|
||||
});
|
||||
|
||||
test('an identical snapshot leaves the drawn entities untouched', async () => {
|
||||
const { layer, sources } = harness(async () => threeCells());
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
const first = sources[0].get(0);
|
||||
await layer.update();
|
||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
||||
assert.equal(sources[0].get(0), first);
|
||||
});
|
||||
|
||||
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] });
|
||||
let calls = 0;
|
||||
const h = harness(
|
||||
async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error('adsb.lol HTTP 502');
|
||||
return { rows: threeHealthy() };
|
||||
},
|
||||
{ initialize: false },
|
||||
);
|
||||
const manager = new LayerLifecycle(h.viewer);
|
||||
manager.register(h.layer);
|
||||
h.layer.attachDataManager(manager);
|
||||
const changes = [];
|
||||
manager.subscribe((c) => changes.push(c.type));
|
||||
assert.equal(await manager.setEnabled(h.layer.id, true), true);
|
||||
assert.equal(manager.isEnabled(h.layer.id), true);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'unavailable');
|
||||
assert.equal(h.layer.getStats().retryInSec, 30);
|
||||
assert.match(
|
||||
h.layer.getRowControls().info,
|
||||
/HTTP 502.*Retrying automatically at/,
|
||||
);
|
||||
assert.equal(changes.includes('visibility-failed'), false);
|
||||
assert.equal(
|
||||
await manager.refreshLayer(h.layer.id),
|
||||
false,
|
||||
'feed remains honestly unhealthy during cooldown',
|
||||
);
|
||||
assert.equal(calls, 1, 'a manual refresh cannot bypass cooldown');
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(manager.isEnabled(h.layer.id), true);
|
||||
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
|
||||
assert.equal(h.layer.getStats().count, 1);
|
||||
assert.equal(h.layer.getStats().retryInSec, null);
|
||||
assert.doesNotMatch(h.layer.getRowControls().info, /Retrying/);
|
||||
await manager.destroyAll();
|
||||
});
|
||||
|
||||
test('provider cooldown is preserved across movement and toggling; disable cancels retry work', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const h = harness(async () => {
|
||||
calls++;
|
||||
if (calls === 1)
|
||||
throw Object.assign(new Error('Rate limited (HTTP 429)'), {
|
||||
retryAfterMs: 3600000,
|
||||
});
|
||||
return { rows: threeHealthy() };
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.setAnchor({ latitude: 10, longitude: 20 });
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
await h.layer.update();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(h.layer.getStats().retryInSec, 3570);
|
||||
h.layer.disable();
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(calls, 1, 'reenabling does not erase a provider deadline');
|
||||
h.advance(3540000);
|
||||
t.mock.timers.tick(3540000);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(h.anchors.at(-1), { latitude: 10, longitude: 20 });
|
||||
h.layer.destroy();
|
||||
|
||||
const stopped = harness(async () => {
|
||||
throw new Error('network unavailable');
|
||||
});
|
||||
stopped.layer.enable();
|
||||
await stopped.layer.update();
|
||||
stopped.layer.disable();
|
||||
stopped.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
assert.equal(stopped.anchors.length, 1, 'disabled layers do not retry');
|
||||
stopped.layer.destroy();
|
||||
});
|
||||
|
||||
test('repeated acquisition failures back off, while genuine non-retryable failures reject startup', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const h = harness(async () => {
|
||||
calls++;
|
||||
throw new Error('network unavailable');
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().retryInSec, 30);
|
||||
h.advance(30000);
|
||||
t.mock.timers.tick(30000);
|
||||
await flush();
|
||||
assert.equal(h.layer.getStats().retryInSec, 60);
|
||||
h.advance(60000);
|
||||
t.mock.timers.tick(60000);
|
||||
await flush();
|
||||
assert.equal(h.layer.getStats().retryInSec, 120);
|
||||
assert.equal(calls, 3);
|
||||
h.layer.destroy();
|
||||
const fatal = harness(async () => {
|
||||
throw Object.assign(new Error('invalid request (HTTP 400)'), {
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
fatal.layer.enable();
|
||||
assert.equal(await fatal.layer.update(), false);
|
||||
assert.equal(fatal.layer.getStats().retryInSec, null);
|
||||
fatal.layer.destroy();
|
||||
});
|
||||
|
||||
test('GERMAN: stale snapshot keeps the source observation age in row stats', async () => {
|
||||
const h = harness(async () => ({
|
||||
rows: threeHealthy(),
|
||||
ageMs: 120000,
|
||||
stale: true,
|
||||
}));
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().lastUpdate, h.now() - 120000);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const h = harness(async () => {
|
||||
calls++;
|
||||
return { rows: [] };
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.setAnchor({ latitude: 51, longitude: 31 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(2499);
|
||||
t.mock.timers.tick(2499);
|
||||
await flush();
|
||||
assert.equal(calls, 1);
|
||||
h.advance(1);
|
||||
t.mock.timers.tick(1);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
h.advance(3000);
|
||||
h.setAnchor({ latitude: 52, longitude: 32 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(599);
|
||||
t.mock.timers.tick(599);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
h.advance(1);
|
||||
t.mock.timers.tick(1);
|
||||
await flush();
|
||||
assert.equal(calls, 3);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('camera movement during initial activation waits for acquisition before following up', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const calls = [];
|
||||
let resolve;
|
||||
const h = harness((anchor) => {
|
||||
calls.push(anchor);
|
||||
return calls.length === 1
|
||||
? new Promise((r) => (resolve = r))
|
||||
: Promise.resolve({ rows: threeHealthy() });
|
||||
});
|
||||
h.layer.enable();
|
||||
const initial = h.layer.update();
|
||||
h.setAnchor({ latitude: 51, longitude: 31 });
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
h.advance(2500);
|
||||
t.mock.timers.tick(2500);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
resolve({ rows: threeHealthy() });
|
||||
assert.equal(await initial, true);
|
||||
h.advance(600);
|
||||
t.mock.timers.tick(600);
|
||||
await flush();
|
||||
assert.equal(calls.length, 2);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('monotonic camera scheduling is unaffected by wall-clock rollback', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = harness(async () => ({ rows: [] }));
|
||||
h.layer.destroy();
|
||||
let wall = 1800000000000,
|
||||
mono = 0,
|
||||
anchor = { latitude: 50, longitude: 30 },
|
||||
calls = 0;
|
||||
const layer = createGnssIntegrityLayer({
|
||||
source: {
|
||||
getSnapshot: async () => {
|
||||
calls++;
|
||||
return { rows: [], ageMs: 0 };
|
||||
},
|
||||
},
|
||||
viewAnchor: () => anchor,
|
||||
now: () => wall,
|
||||
monotonicNow: () => mono,
|
||||
});
|
||||
layer.init(h.viewer);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
wall -= 3600000;
|
||||
mono = 3000;
|
||||
anchor = { latitude: 51, longitude: 31 };
|
||||
h.viewer.camera.changed.raiseEvent();
|
||||
mono += 600;
|
||||
t.mock.timers.tick(600);
|
||||
await flush();
|
||||
assert.equal(calls, 2);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
|
||||
const h = harness(async () => ({ rows: threeHealthy() }));
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
const fill = h.sources[0].get(0);
|
||||
let updates = 0;
|
||||
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
|
||||
fill.update({ passes: { pick: true } });
|
||||
assert.equal(updates, 0);
|
||||
fill.update({ passes: { render: true, pick: false } });
|
||||
assert.equal(updates, 1);
|
||||
h.layer.destroy();
|
||||
assert.equal(fill.isDestroyed(), true);
|
||||
});
|
||||
|
||||
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
|
||||
assert.equal(defaultGnssViewAnchor({}), null);
|
||||
const viewer = {
|
||||
camera: {
|
||||
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
|
||||
},
|
||||
};
|
||||
const rectangleAnchor = defaultGnssViewAnchor(viewer);
|
||||
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
|
||||
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
|
||||
viewer.camera = {
|
||||
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
|
||||
};
|
||||
const nadirAnchor = defaultGnssViewAnchor(viewer);
|
||||
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
|
||||
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Low navigation accuracy reported by ADS-B aircraft, binned into cells.
|
||||
*
|
||||
* Aircraft broadcasting DO-260 version 1/2 ADS-B report how much they trust
|
||||
* their own position: NIC (integrity containment radius) and NACp (position
|
||||
* accuracy). A cluster of aircraft reporting low values in one area is a
|
||||
* navigation-integrity anomaly. Jamming or spoofing is one common cause, so
|
||||
* such clusters are a well-known public proxy for GNSS interference, but
|
||||
* avionics faults and old transponders also report low values: suspected
|
||||
* interference is a second, unvalidated interpretation, never the primary
|
||||
* state.
|
||||
*
|
||||
* Three separately sourced parts make up the method (see `gnssProvenance`):
|
||||
* - Per-aircraft classifier, defined by GEV: aircraft-reported NIC < 7
|
||||
* (except unknown NIC 0 with NACp >= 10) or NACp < 8 (borrowed
|
||||
* from the US 14 CFR 91.227(c) ADS-B Out performance minima, which are not
|
||||
* a published interference classifier) or the readsb/tar1090 `gpsOkBefore`
|
||||
* flag. gpsjam.org does not publish its per-aircraft threshold.
|
||||
* - Cell aggregation: gpsjam.org's published formula and 2% / 10% bands.
|
||||
* - Window: a GEV-defined rolling 30 minutes over the views the user visits.
|
||||
*/
|
||||
|
||||
/** Position source can switch to MLAT after GPS loss; quality must remain aircraft-reported. */
|
||||
const QUALIFYING_TYPES = new Set(['adsb_icao', 'mlat']);
|
||||
/** Restrict to decoded DO-260A/B quality fields; readsb does not decode v3 quality as v3. */
|
||||
const QUALIFYING_VERSIONS = new Set([1, 2]);
|
||||
/** NIC below 7: containment radius of 0.2 NM or more (below the US 91.227 ADS-B Out minimum). GEV threshold. */
|
||||
export const GNSS_NIC_THRESHOLD = 7;
|
||||
/** NACp below 8: position uncertainty of 93 m or more (below the US 91.227 minimum). GEV threshold. */
|
||||
export const GNSS_NACP_THRESHOLD = 8;
|
||||
/** Positions older than this are not current evidence of the local sky. */
|
||||
const MAX_POSITION_AGE_S = 60;
|
||||
/** Cell edge in degrees: roughly 55 km north-south, similar in area to an H3 res-4 cell. */
|
||||
export const GNSS_CELL_DEG = 0.5;
|
||||
/** Hide sparse one- and two-aircraft cells; the aggregation separately discounts one bad report. */
|
||||
export const GNSS_MIN_AIRCRAFT = 3;
|
||||
/** Rolling window over which aircraft seen in a cell are counted. */
|
||||
export const GNSS_WINDOW_MS = 30 * 60_000;
|
||||
/** gpsjam.org band edges, in percent of aircraft (after discounting one). */
|
||||
export const GNSS_MEDIUM_PERCENT = 2;
|
||||
export const GNSS_HIGH_PERCENT = 10;
|
||||
|
||||
/** Primary cell state: what the data shows (low navigation accuracy). */
|
||||
export const GNSS_ANOMALY_STATE = 'navigation-integrity-anomaly';
|
||||
export const GNSS_NOMINAL_STATE = 'nominal';
|
||||
/** Secondary, unvalidated reading of an anomaly cell. */
|
||||
export const GNSS_INTERPRETATION = 'suspected-gnss-interference';
|
||||
|
||||
/** The per-aircraft rule; GEV-defined and not validated against known events. */
|
||||
export const GNSS_CLASSIFIER = Object.freeze({
|
||||
id: 'gev-nic-nacp-v2',
|
||||
rule: `gpsOkBefore set || aircraft-reported NACp < ${GNSS_NACP_THRESHOLD} || (aircraft-reported NIC < ${GNSS_NIC_THRESHOLD} && !(NIC === 0 && NACp >= 10))`,
|
||||
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
|
||||
definedBy: 'GEV',
|
||||
validated: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Machine-readable provenance of the cell output: which part of the method
|
||||
* comes from where, and which parts are unvalidated.
|
||||
*
|
||||
* @param {{windowMs?: number}} [options] The window the layer actually uses.
|
||||
*/
|
||||
export function gnssProvenance({ windowMs = GNSS_WINDOW_MS } = {}) {
|
||||
return Object.freeze({
|
||||
classifier: GNSS_CLASSIFIER,
|
||||
aggregation: Object.freeze({
|
||||
id: 'gpsjam-cell-bands',
|
||||
formula: '100 * max(0, bad - 1) / total',
|
||||
bands: Object.freeze([GNSS_MEDIUM_PERCENT, GNSS_HIGH_PERCENT]),
|
||||
source: 'https://gpsjam.org/faq',
|
||||
definedBy: 'gpsjam.org',
|
||||
}),
|
||||
window: Object.freeze({
|
||||
minutes: windowMs / 60_000,
|
||||
scope: 'visited-view',
|
||||
cellDeg: GNSS_CELL_DEG,
|
||||
minAircraft: GNSS_MIN_AIRCRAFT,
|
||||
definedBy: 'GEV',
|
||||
}),
|
||||
state: Object.freeze({
|
||||
id: GNSS_ANOMALY_STATE,
|
||||
meaning: 'low navigation accuracy reported by ADS-B aircraft',
|
||||
}),
|
||||
interpretation: Object.freeze({
|
||||
id: GNSS_INTERPRETATION,
|
||||
validated: false,
|
||||
note: 'one possible cause of an anomaly cell; avionics faults also report low values',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Provenance at the default window. */
|
||||
export const GNSS_PROVENANCE = gnssProvenance();
|
||||
|
||||
const HEX_PATTERN = /^~?[0-9a-f]{6}$/;
|
||||
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
/**
|
||||
* Reduce one adsb.lol / readsb `aircraft.json`-style payload to the integrity
|
||||
* facts the layer needs. Returns null when the payload has no aircraft array.
|
||||
*
|
||||
* @param {{ac?: object[], now?: number}} payload - readsb-style snapshot.
|
||||
* @returns {{hex: string, lat: number, lon: number, nic: number|null, nacp: number|null, gpsLost: boolean, degraded: boolean}[]|null}
|
||||
*/
|
||||
export function normalizeGnssAircraft(payload) {
|
||||
if (!Array.isArray(payload?.ac)) return null;
|
||||
const rows = [];
|
||||
const seen = new Set();
|
||||
for (const aircraft of payload.ac) {
|
||||
if (!aircraft || typeof aircraft !== 'object') continue;
|
||||
const hex = typeof aircraft.hex === 'string' ? aircraft.hex : '';
|
||||
if (!HEX_PATTERN.test(hex) || seen.has(hex)) continue;
|
||||
if (!QUALIFYING_TYPES.has(aircraft.type)) continue;
|
||||
if (!QUALIFYING_VERSIONS.has(aircraft.version)) continue;
|
||||
if (aircraft.alt_baro === 'ground' || !finite(aircraft.alt_baro)) continue;
|
||||
let { lat, lon } = aircraft;
|
||||
const derived = (field) =>
|
||||
aircraft.tisb?.includes(field) || aircraft.mlat?.includes(field);
|
||||
const nic = finite(aircraft.nic) && !derived('nic') ? aircraft.nic : null;
|
||||
const nacp =
|
||||
finite(aircraft.nac_p) && !derived('nac_p') ? aircraft.nac_p : null;
|
||||
if (!finite(lat) || Math.abs(lat) > 90) continue;
|
||||
if (!finite(lon) || Math.abs(lon) > 180) continue;
|
||||
// Only a position confirmed within the last minute is current evidence; a
|
||||
// missing, invalid or negative age confirms nothing.
|
||||
const age = aircraft.seen_pos;
|
||||
if (!finite(age) || age < 0 || age > MAX_POSITION_AGE_S) continue;
|
||||
// readsb exposes the flag while GPS is lost (15 s–15 min after the
|
||||
// last good position), and removes it on recovery. Locate that evidence
|
||||
// at the last good GNSS position, rather than a later MLAT estimate.
|
||||
const gpsLost = finite(aircraft.gpsOkBefore) && aircraft.gpsOkBefore > 0;
|
||||
if (nic === null && nacp === null && !gpsLost) continue;
|
||||
if (
|
||||
gpsLost &&
|
||||
finite(aircraft.gpsOkLat) &&
|
||||
Math.abs(aircraft.gpsOkLat) <= 90 &&
|
||||
finite(aircraft.gpsOkLon) &&
|
||||
Math.abs(aircraft.gpsOkLon) <= 180
|
||||
) {
|
||||
lat = aircraft.gpsOkLat;
|
||||
lon = aircraft.gpsOkLon;
|
||||
}
|
||||
seen.add(hex);
|
||||
rows.push({
|
||||
hex,
|
||||
lat,
|
||||
lon,
|
||||
nic,
|
||||
nacp,
|
||||
gpsLost,
|
||||
degraded:
|
||||
gpsLost ||
|
||||
(nacp !== null && nacp < GNSS_NACP_THRESHOLD) ||
|
||||
(nic !== null &&
|
||||
nic < GNSS_NIC_THRESHOLD &&
|
||||
!(nic === 0 && nacp !== null && nacp >= 10)),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Stable key of the cell containing a position. */
|
||||
export function gnssCellKey(lat, lon, cellDeg = GNSS_CELL_DEG) {
|
||||
const row = Math.floor((Math.min(lat, 89.999999) + 90) / cellDeg);
|
||||
const col = Math.floor((Math.min(lon, 179.999999) + 180) / cellDeg);
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one snapshot into a rolling observation store. Each aircraft counts
|
||||
* once per cell per window. Presence and degraded evidence age separately: a
|
||||
* degraded report keeps the aircraft degraded in that cell for one window
|
||||
* after the report itself, however many healthy reports follow, so an
|
||||
* aircraft that loses GNSS mid-cell is not averaged away and a recovered one
|
||||
* is not held degraded forever.
|
||||
*
|
||||
* Every row is dated by when the snapshot was observed, not when it arrived:
|
||||
* replaying a cached or stale snapshot never makes its evidence younger, and
|
||||
* a snapshot older than the window adds nothing.
|
||||
*
|
||||
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
|
||||
* @param {ReturnType<typeof normalizeGnssAircraft>} rows
|
||||
* @param {number} nowMs
|
||||
* @param {{windowMs?: number, cellDeg?: number, observedAt?: number}} [options]
|
||||
* `observedAt` is the snapshot's observation time (defaults to `nowMs`;
|
||||
* a future time is clamped to `nowMs`, a non-finite one adds nothing).
|
||||
* @returns {Map} The same store, pruned to the window.
|
||||
*/
|
||||
export function accumulateGnssObservations(
|
||||
store,
|
||||
rows,
|
||||
nowMs,
|
||||
{
|
||||
windowMs = GNSS_WINDOW_MS,
|
||||
cellDeg = GNSS_CELL_DEG,
|
||||
observedAt = nowMs,
|
||||
} = {},
|
||||
) {
|
||||
const at = Number.isFinite(observedAt) ? Math.min(observedAt, nowMs) : NaN;
|
||||
if (Number.isFinite(at) && nowMs - at <= windowMs) {
|
||||
for (const row of rows || []) {
|
||||
if (!Number.isFinite(row?.lat) || !Number.isFinite(row?.lon)) continue;
|
||||
const cell = gnssCellKey(row.lat, row.lon, cellDeg);
|
||||
const key = `${row.hex}|${cell}`;
|
||||
const previous = store.get(key);
|
||||
const previousDegradedAt = previous?.degradedAt ?? null;
|
||||
store.set(key, {
|
||||
cell,
|
||||
seenAt: Math.max(previous?.seenAt ?? at, at),
|
||||
degradedAt: row.degraded
|
||||
? Math.max(previousDegradedAt ?? at, at)
|
||||
: previousDegradedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return pruneGnssObservations(store, nowMs, { windowMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop observations older than the window and degraded evidence older than
|
||||
* the window, without adding anything. Used when no fresh snapshot arrives.
|
||||
*
|
||||
* @param {Map<string, {cell: string, seenAt: number, degradedAt: number|null}>} store
|
||||
* @param {number} nowMs
|
||||
* @param {{windowMs?: number}} [options]
|
||||
* @returns {Map} The same store.
|
||||
*/
|
||||
export function pruneGnssObservations(
|
||||
store,
|
||||
nowMs,
|
||||
{ windowMs = GNSS_WINDOW_MS } = {},
|
||||
) {
|
||||
for (const [key, entry] of store) {
|
||||
if (nowMs - entry.seenAt > windowMs) store.delete(key);
|
||||
else if (entry.degradedAt != null && nowMs - entry.degradedAt > windowMs)
|
||||
store.set(key, { ...entry, degradedAt: null });
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation-integrity level of a cell, using gpsjam.org's published cell
|
||||
* formula and bands: the first degraded aircraft is discounted to limit false
|
||||
* positives, then under 2% is low, 2–10% medium and over 10% high. Medium and
|
||||
* high cells are navigation-integrity anomalies.
|
||||
*/
|
||||
export function gnssIntegrityLevel(total, degraded) {
|
||||
const percent = total > 0 ? (100 * Math.max(0, degraded - 1)) / total : 0;
|
||||
const level =
|
||||
percent > GNSS_HIGH_PERCENT
|
||||
? 'high'
|
||||
: percent >= GNSS_MEDIUM_PERCENT
|
||||
? 'medium'
|
||||
: 'low';
|
||||
const anomaly = level !== 'low';
|
||||
return {
|
||||
percent,
|
||||
level,
|
||||
state: anomaly ? GNSS_ANOMALY_STATE : GNSS_NOMINAL_STATE,
|
||||
interpretation: anomaly ? GNSS_INTERPRETATION : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the observation store into displayable cells. Expects a store
|
||||
* already pruned to the window by `accumulateGnssObservations` or
|
||||
* `pruneGnssObservations`.
|
||||
*
|
||||
* @returns {{id: string, south: number, west: number, north: number, east: number, lat: number, lon: number, aircraft: number, degraded: number, percentDegraded: number, level: 'low'|'medium'|'high', state: string, interpretation: string|null}[]}
|
||||
*/
|
||||
export function binGnssCells(
|
||||
store,
|
||||
{ cellDeg = GNSS_CELL_DEG, minAircraft = GNSS_MIN_AIRCRAFT } = {},
|
||||
) {
|
||||
const tallies = new Map();
|
||||
for (const { cell, degradedAt } of store.values()) {
|
||||
const tally = tallies.get(cell) || { aircraft: 0, degraded: 0 };
|
||||
tally.aircraft += 1;
|
||||
if (degradedAt != null) tally.degraded += 1;
|
||||
tallies.set(cell, tally);
|
||||
}
|
||||
const cells = [];
|
||||
for (const [id, { aircraft, degraded }] of tallies) {
|
||||
if (aircraft < minAircraft) continue;
|
||||
const [row, col] = id.split(':').map(Number);
|
||||
const south = row * cellDeg - 90;
|
||||
const west = col * cellDeg - 180;
|
||||
const { percent, level, state, interpretation } = gnssIntegrityLevel(
|
||||
aircraft,
|
||||
degraded,
|
||||
);
|
||||
cells.push({
|
||||
id,
|
||||
south,
|
||||
west,
|
||||
north: south + cellDeg,
|
||||
east: west + cellDeg,
|
||||
lat: south + cellDeg / 2,
|
||||
lon: west + cellDeg / 2,
|
||||
aircraft,
|
||||
degraded,
|
||||
percentDegraded: Math.round(percent * 10) / 10,
|
||||
level,
|
||||
state,
|
||||
interpretation,
|
||||
});
|
||||
}
|
||||
return cells.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** Legend colors: gpsjam's green / amber / red convention. */
|
||||
export const GNSS_LEVEL_COLORS = Object.freeze({
|
||||
low: '#3fb950',
|
||||
medium: '#e3b341',
|
||||
high: '#f85149',
|
||||
});
|
||||
@@ -0,0 +1,491 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
accumulateGnssObservations,
|
||||
binGnssCells,
|
||||
GNSS_CLASSIFIER,
|
||||
GNSS_PROVENANCE,
|
||||
GNSS_WINDOW_MS,
|
||||
gnssCellKey,
|
||||
gnssIntegrityLevel,
|
||||
gnssProvenance,
|
||||
normalizeGnssAircraft,
|
||||
pruneGnssObservations,
|
||||
} from './records.js';
|
||||
|
||||
const aircraft = (overrides = {}) => ({
|
||||
hex: '4ca7b5',
|
||||
type: 'adsb_icao',
|
||||
version: 2,
|
||||
alt_baro: 36000,
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nac_p: 10,
|
||||
seen_pos: 1.2,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('normalization keeps airborne decoded ADS-B v1/v2 quality with current positions', () => {
|
||||
const rows = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft(),
|
||||
aircraft({
|
||||
hex: '000001',
|
||||
type: 'mlat',
|
||||
nic: undefined,
|
||||
nac_p: undefined,
|
||||
}),
|
||||
aircraft({ hex: '000002', type: 'tisb_icao' }),
|
||||
aircraft({ hex: '000003', version: 0 }),
|
||||
aircraft({ hex: '000004', alt_baro: 'ground' }),
|
||||
aircraft({ hex: '000005', seen_pos: 120 }),
|
||||
aircraft({ hex: '000006', nic: undefined, nac_p: undefined }),
|
||||
aircraft({ hex: '000007', lat: 95 }),
|
||||
aircraft({ hex: '000008', version: undefined }),
|
||||
// Only DO-260A/B (versions 1 and 2) define NIC and NACp.
|
||||
aircraft({ hex: '000009', version: 3 }),
|
||||
aircraft({ hex: '00000a', version: '2' }),
|
||||
aircraft({ hex: '00000b', version: 1.5 }),
|
||||
// A position is current only when its age is known, finite and 0..60 s.
|
||||
aircraft({ hex: '00000c', seen_pos: undefined }),
|
||||
aircraft({ hex: '00000d', seen_pos: null }),
|
||||
aircraft({ hex: '00000e', seen_pos: -0.5 }),
|
||||
aircraft({ hex: '00000f', seen_pos: '1.2' }),
|
||||
aircraft({ hex: '000010', seen_pos: Number.NaN }),
|
||||
aircraft({ hex: '000011', seen_pos: 60.1 }),
|
||||
aircraft({ hex: 'not-hex' }),
|
||||
aircraft(),
|
||||
// A non-ICAO (`~`) address still self-reports over 1090ES ADS-B.
|
||||
aircraft({ hex: '~a1b2c3' }),
|
||||
// Version 1, and both ends of the position-age range, qualify.
|
||||
aircraft({ hex: 'b00001', version: 1, seen_pos: 0 }),
|
||||
aircraft({ hex: 'b00002', seen_pos: 60 }),
|
||||
null,
|
||||
],
|
||||
});
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
hex: '4ca7b5',
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
},
|
||||
{
|
||||
hex: '~a1b2c3',
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
},
|
||||
...['b00001', 'b00002'].map((hex) => ({
|
||||
hex,
|
||||
lat: 50.1,
|
||||
lon: 19.9,
|
||||
nic: 8,
|
||||
nacp: 10,
|
||||
gpsLost: false,
|
||||
degraded: false,
|
||||
})),
|
||||
]);
|
||||
assert.equal(normalizeGnssAircraft({}), null);
|
||||
assert.equal(normalizeGnssAircraft(null), null);
|
||||
});
|
||||
|
||||
test('low NIC, low NACp or a readsb GPS-loss flag each mark an aircraft degraded', () => {
|
||||
const rows = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft({ hex: 'a00001', nic: 6 }),
|
||||
aircraft({ hex: 'a00002', nac_p: 7 }),
|
||||
aircraft({ hex: 'a00003', gpsOkBefore: 1790690000.1 }),
|
||||
aircraft({ hex: 'a00004', nic: 7, nac_p: 8 }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
rows.map(({ hex, degraded, gpsLost }) => [hex, degraded, gpsLost]),
|
||||
[
|
||||
['a00001', true, false],
|
||||
['a00002', true, false],
|
||||
['a00003', true, true],
|
||||
['a00004', false, false],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('cell keys are stable half-degree bins, clamped at the poles and antimeridian', () => {
|
||||
assert.equal(gnssCellKey(50.1, 19.9), gnssCellKey(50.4, 19.6));
|
||||
assert.notEqual(gnssCellKey(50.1, 19.9), gnssCellKey(50.6, 19.9));
|
||||
assert.equal(gnssCellKey(90, 180), gnssCellKey(89.9, 179.9));
|
||||
assert.equal(gnssCellKey(-90, -180), '0:0');
|
||||
// Just south-west of the origin falls in the last cell before 0°, not cell 0.
|
||||
assert.equal(gnssCellKey(-0.1, -0.1), '179:359');
|
||||
});
|
||||
|
||||
test('integrity level follows the gpsjam.org published cell formula and bands', () => {
|
||||
const nominal = {
|
||||
percent: 0,
|
||||
level: 'low',
|
||||
state: 'nominal',
|
||||
interpretation: null,
|
||||
};
|
||||
assert.deepEqual(gnssIntegrityLevel(10, 0), nominal);
|
||||
// One degraded aircraft is always discounted.
|
||||
assert.deepEqual(gnssIntegrityLevel(10, 1), nominal);
|
||||
assert.equal(gnssIntegrityLevel(50, 2).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(10, 2).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(10, 3).level, 'high');
|
||||
assert.equal(gnssIntegrityLevel(0, 0).level, 'low');
|
||||
});
|
||||
|
||||
test('the primary state is a navigation-integrity anomaly; interference is only the secondary reading', () => {
|
||||
for (const [total, bad] of [
|
||||
[50, 2],
|
||||
[10, 3],
|
||||
]) {
|
||||
const { state, interpretation } = gnssIntegrityLevel(total, bad);
|
||||
assert.equal(state, 'navigation-integrity-anomaly');
|
||||
assert.equal(interpretation, 'suspected-gnss-interference');
|
||||
}
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[
|
||||
{ hex: 'h1', lat: 10.1, lon: 10.1, degraded: true },
|
||||
{ hex: 'h2', lat: 10.2, lon: 10.2, degraded: true },
|
||||
{ hex: 'h3', lat: 10.3, lon: 10.3, degraded: false },
|
||||
],
|
||||
0,
|
||||
);
|
||||
const [cell] = binGnssCells(store);
|
||||
assert.equal(cell.level, 'high');
|
||||
assert.equal(cell.state, 'navigation-integrity-anomaly');
|
||||
assert.equal(cell.interpretation, 'suspected-gnss-interference');
|
||||
for (const key of Object.keys(cell))
|
||||
assert.doesNotMatch(key, /interference|jamming|spoof/i);
|
||||
});
|
||||
|
||||
test('provenance separates the GEV classifier from the gpsjam aggregation and the GEV window', () => {
|
||||
assert.deepEqual(GNSS_PROVENANCE.classifier, {
|
||||
id: 'gev-nic-nacp-v2',
|
||||
rule: 'gpsOkBefore set || aircraft-reported NACp < 8 || (aircraft-reported NIC < 7 && !(NIC === 0 && NACp >= 10))',
|
||||
basis: '14 CFR 91.227(c) ADS-B Out performance minima (US regulation)',
|
||||
definedBy: 'GEV',
|
||||
validated: false,
|
||||
});
|
||||
assert.equal(GNSS_PROVENANCE.classifier, GNSS_CLASSIFIER);
|
||||
assert.deepEqual(GNSS_PROVENANCE.aggregation, {
|
||||
id: 'gpsjam-cell-bands',
|
||||
formula: '100 * max(0, bad - 1) / total',
|
||||
bands: [2, 10],
|
||||
source: 'https://gpsjam.org/faq',
|
||||
definedBy: 'gpsjam.org',
|
||||
});
|
||||
assert.deepEqual(GNSS_PROVENANCE.window, {
|
||||
minutes: 30,
|
||||
scope: 'visited-view',
|
||||
cellDeg: 0.5,
|
||||
minAircraft: 3,
|
||||
definedBy: 'GEV',
|
||||
});
|
||||
assert.equal(GNSS_PROVENANCE.state.id, 'navigation-integrity-anomaly');
|
||||
assert.equal(
|
||||
GNSS_PROVENANCE.interpretation.id,
|
||||
'suspected-gnss-interference',
|
||||
);
|
||||
assert.equal(GNSS_PROVENANCE.interpretation.validated, false);
|
||||
assert.ok(Object.isFrozen(GNSS_PROVENANCE));
|
||||
assert.ok(Object.isFrozen(GNSS_PROVENANCE.aggregation.bands));
|
||||
// The bands in provenance are the ones the level function applies.
|
||||
const [medium, high] = GNSS_PROVENANCE.aggregation.bands;
|
||||
assert.equal(gnssIntegrityLevel(100, 1 + medium).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(100, 1 + high).level, 'medium');
|
||||
assert.equal(gnssIntegrityLevel(100, 2 + high).level, 'high');
|
||||
assert.equal(gnssProvenance({ windowMs: 10 * 60_000 }).window.minutes, 10);
|
||||
});
|
||||
|
||||
test('observations count each aircraft once per cell and expire after the window', () => {
|
||||
const store = new Map();
|
||||
const at = (lat, hex, degraded) => ({ hex, lat, lon: 20.1, degraded });
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[at(50.1, 'a1', true), at(50.2, 'a2', false), at(50.3, 'a3', false)],
|
||||
0,
|
||||
{ windowMs: 1000 },
|
||||
);
|
||||
// a1 recovers inside the same cell: it stays degraded for the window.
|
||||
accumulateGnssObservations(store, [at(50.2, 'a1', false)], 500, {
|
||||
windowMs: 1000,
|
||||
});
|
||||
let [cell] = binGnssCells(store);
|
||||
assert.equal(cell.aircraft, 3);
|
||||
assert.equal(cell.degraded, 1);
|
||||
assert.equal(cell.level, 'low');
|
||||
assert.deepEqual(
|
||||
[cell.south, cell.west, cell.north, cell.east],
|
||||
[50, 20, 50.5, 20.5],
|
||||
);
|
||||
|
||||
accumulateGnssObservations(store, [], 1200, { windowMs: 1000 });
|
||||
assert.equal(store.size, 1);
|
||||
assert.deepEqual(binGnssCells(store), []);
|
||||
});
|
||||
|
||||
const MIN = 60_000;
|
||||
const report = (hex, degraded, lat = 50.1) => ({
|
||||
hex,
|
||||
lat,
|
||||
lon: 20.1,
|
||||
degraded,
|
||||
});
|
||||
const entry = (store, hex) =>
|
||||
[...store].find(([key]) => key.startsWith(`${hex}|`))?.[1];
|
||||
|
||||
test('degraded evidence expires one window after the degraded report, despite continuing healthy reports', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
// a1 keeps reporting healthy every minute in the same cell.
|
||||
for (let minute = 1; minute <= 30; minute += 1)
|
||||
accumulateGnssObservations(store, [report('a1', false)], minute * MIN);
|
||||
assert.equal(entry(store, 'a1').seenAt, 30 * MIN);
|
||||
assert.equal(
|
||||
entry(store, 'a1').degradedAt,
|
||||
0,
|
||||
'healthy reports do not refresh the degraded evidence',
|
||||
);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 31 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// A new degraded report starts its own window.
|
||||
accumulateGnssObservations(store, [report('a1', true)], 32 * MIN);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 40 * MIN);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 32 * MIN);
|
||||
});
|
||||
|
||||
test('a degraded cell turns healthy once its degraded evidence is older than the window', () => {
|
||||
const store = new Map();
|
||||
const healthy = [report('a2', false), report('a3', false)];
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', true), report('b1', true), ...healthy],
|
||||
0,
|
||||
);
|
||||
assert.equal(binGnssCells(store)[0].level, 'high');
|
||||
for (let minute = 10; minute <= 30; minute += 10)
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', false), report('b1', false), ...healthy],
|
||||
minute * MIN,
|
||||
);
|
||||
assert.equal(binGnssCells(store)[0].degraded, 2);
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[report('a1', false), report('b1', false), ...healthy],
|
||||
31 * MIN,
|
||||
);
|
||||
const [cell] = binGnssCells(store);
|
||||
assert.equal(cell.aircraft, 4);
|
||||
assert.equal(cell.degraded, 0);
|
||||
assert.equal(cell.level, 'low');
|
||||
});
|
||||
|
||||
test('an aircraft returning after the window starts fresh', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
// a1 leaves; nothing is heard from it for longer than the window.
|
||||
accumulateGnssObservations(store, [], 31 * MIN);
|
||||
assert.equal(store.size, 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 32 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 32 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// Returning degraded dates the evidence from the new report.
|
||||
const again = new Map();
|
||||
accumulateGnssObservations(again, [report('a9', true)], 0);
|
||||
accumulateGnssObservations(again, [], 31 * MIN);
|
||||
accumulateGnssObservations(again, [report('a9', true)], 33 * MIN);
|
||||
assert.equal(entry(again, 'a9').degradedAt, 33 * MIN);
|
||||
});
|
||||
|
||||
test('an aircraft heard healthy right after the window drops its stale degraded evidence', () => {
|
||||
// No prune runs in between: the old entry is still in the store when the
|
||||
// healthy report arrives, so the new report alone must not carry it over.
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 31 * MIN);
|
||||
assert.deepEqual(entry(store, 'a1'), {
|
||||
cell: gnssCellKey(50.1, 20.1),
|
||||
seenAt: 31 * MIN,
|
||||
degradedAt: null,
|
||||
});
|
||||
// Exactly one window after the degraded report it still counts.
|
||||
const edge = new Map();
|
||||
accumulateGnssObservations(edge, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(edge, [report('a1', false)], 30 * MIN);
|
||||
assert.equal(entry(edge, 'a1').degradedAt, 0);
|
||||
});
|
||||
|
||||
test('rows are dated by the snapshot observation time, so a replay never restarts the window', () => {
|
||||
const store = new Map();
|
||||
const snapshot = [report('a1', true), report('a2', false)];
|
||||
accumulateGnssObservations(store, snapshot, 0, { observedAt: 0 });
|
||||
// The same snapshot replayed (cached or stale) 29 minutes later.
|
||||
accumulateGnssObservations(store, snapshot, 29 * MIN, { observedAt: 0 });
|
||||
assert.equal(entry(store, 'a1').seenAt, 0);
|
||||
assert.equal(entry(store, 'a1').degradedAt, 0);
|
||||
accumulateGnssObservations(store, snapshot, 31 * MIN, { observedAt: 0 });
|
||||
assert.equal(store.size, 0, 'a replay older than the window adds nothing');
|
||||
|
||||
// An older replay never moves an entry's age backwards either.
|
||||
accumulateGnssObservations(store, snapshot, 40 * MIN);
|
||||
accumulateGnssObservations(store, [report('a2', true)], 41 * MIN, {
|
||||
observedAt: 35 * MIN,
|
||||
});
|
||||
assert.equal(entry(store, 'a2').seenAt, 40 * MIN);
|
||||
assert.equal(entry(store, 'a2').degradedAt, 35 * MIN);
|
||||
|
||||
// A future time is clamped to now; an undated snapshot adds nothing.
|
||||
accumulateGnssObservations(store, [report('f1', true)], 50 * MIN, {
|
||||
observedAt: 99 * MIN,
|
||||
});
|
||||
assert.equal(entry(store, 'f1').seenAt, 50 * MIN);
|
||||
for (const observedAt of [null, Number.NaN])
|
||||
accumulateGnssObservations(store, [report('u1', true)], 50 * MIN, {
|
||||
observedAt,
|
||||
});
|
||||
assert.equal(entry(store, 'u1'), undefined);
|
||||
});
|
||||
|
||||
test('pruning without new rows drops expired observations and expired degraded evidence', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(store, [report('a1', true)], 0);
|
||||
accumulateGnssObservations(store, [report('a1', false)], 20 * MIN);
|
||||
accumulateGnssObservations(store, [report('a2', false)], 0);
|
||||
assert.equal(pruneGnssObservations(store, 31 * MIN), store);
|
||||
assert.deepEqual(
|
||||
[...store.keys()].map((key) => key.split('|')[0]),
|
||||
['a1'],
|
||||
);
|
||||
assert.equal(entry(store, 'a1').degradedAt, null);
|
||||
pruneGnssObservations(store, 51 * MIN);
|
||||
assert.equal(store.size, 0);
|
||||
assert.equal(GNSS_WINDOW_MS, 30 * MIN);
|
||||
});
|
||||
|
||||
test('rows without finite coordinates never key a cell', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[
|
||||
{ hex: 'n1', lat: Number.NaN, lon: 10.1, degraded: true },
|
||||
{ hex: 'n2', lat: 10.1, lon: '10.1', degraded: true },
|
||||
null,
|
||||
],
|
||||
0,
|
||||
);
|
||||
assert.equal(store.size, 0);
|
||||
});
|
||||
|
||||
test('cells below the minimum aircraft count are withheld', () => {
|
||||
const store = new Map();
|
||||
accumulateGnssObservations(
|
||||
store,
|
||||
[
|
||||
{ hex: 'b1', lat: 10.1, lon: 10.1, degraded: true },
|
||||
{ hex: 'b2', lat: 10.2, lon: 10.2, degraded: true },
|
||||
],
|
||||
0,
|
||||
);
|
||||
assert.deepEqual(binGnssCells(store), []);
|
||||
const [cell] = binGnssCells(store, { minAircraft: 2 });
|
||||
assert.equal(cell.level, 'high');
|
||||
assert.equal(cell.percentDegraded, 50);
|
||||
});
|
||||
|
||||
test('MLAT GPS-loss evidence uses the last good GNSS location and ignores MLAT NIC', () => {
|
||||
const [row] = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft({
|
||||
type: 'mlat',
|
||||
nac_p: 0,
|
||||
nic: 0,
|
||||
mlat: ['lat', 'lon', 'nic'],
|
||||
gpsOkBefore: 1800000000,
|
||||
gpsOkLat: 54.2,
|
||||
gpsOkLon: 19.3,
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.ok(row);
|
||||
assert.equal(row.lat, 54.2);
|
||||
assert.equal(row.lon, 19.3);
|
||||
assert.equal(row.nic, null);
|
||||
assert.equal(row.degraded, true);
|
||||
const [healthy] = normalizeGnssAircraft({
|
||||
ac: [aircraft({ type: 'mlat', nic: 0, mlat: ['nic'] })],
|
||||
});
|
||||
assert.equal(healthy.degraded, false);
|
||||
});
|
||||
|
||||
test('TIS-B accuracy fields and unknown containment do not manufacture anomalies', () => {
|
||||
const rows = normalizeGnssAircraft({
|
||||
ac: [
|
||||
aircraft({ hex: 'a00001', nac_p: 0, tisb: ['nac_p'] }),
|
||||
aircraft({ hex: 'a00002', nic: 0, tisb: ['nic'] }),
|
||||
aircraft({ hex: 'a00003', nic: 0, nac_p: 10 }),
|
||||
aircraft({ hex: 'a00004', nic: 0, nac_p: 8 }),
|
||||
aircraft({ hex: 'a00005', nic: 6, nac_p: 10 }),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
rows.map((r) => r.degraded),
|
||||
[false, false, false, true, true],
|
||||
);
|
||||
});
|
||||
|
||||
// Quality-field subsets captured from public adsb.lol US point responses on
|
||||
// 2026-10-14 (34,-118 and 41,-73, radius 250 nm), ODbL 1.0 / adsb.lol contributors.
|
||||
// Only field provenance is reused; the airborne ADS-B envelopes are synthetic.
|
||||
test('public US quality-field fixtures do not turn rebroadcast accuracy into own accuracy', () => {
|
||||
const tisbQuality = {
|
||||
nic: 0,
|
||||
nac_p: 0,
|
||||
tisb: [
|
||||
'altitude',
|
||||
'alt_baro',
|
||||
'gs',
|
||||
'track',
|
||||
'geom_rate',
|
||||
'lat',
|
||||
'lon',
|
||||
'nic',
|
||||
'rc',
|
||||
'nac_p',
|
||||
'nac_v',
|
||||
'sil',
|
||||
'sil_type',
|
||||
],
|
||||
mlat: [],
|
||||
};
|
||||
assert.deepEqual(normalizeGnssAircraft({ ac: [aircraft(tisbQuality)] }), []);
|
||||
const unknownContainment = {
|
||||
nic: 0,
|
||||
nac_p: 10,
|
||||
tisb: ['callsign'],
|
||||
mlat: [],
|
||||
};
|
||||
assert.equal(
|
||||
normalizeGnssAircraft({ ac: [aircraft(unknownContainment)] })[0].degraded,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
function retryDelay(raw, nowMs) {
|
||||
if (!raw) return null;
|
||||
const seconds = Number(raw);
|
||||
const ms = seconds * 1000;
|
||||
if (Number.isFinite(ms) && seconds > 0)
|
||||
return Math.min(3600000, Math.max(5000, ms));
|
||||
const at = Date.parse(raw);
|
||||
return Number.isFinite(at) && at > nowMs
|
||||
? Math.min(3600000, Math.max(5000, at - nowMs))
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Request integrity facts around a view anchor through the same-origin adsb.lol proxy. */
|
||||
export function createAdsbGnssSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ latitude, longitude } = {}, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
|
||||
throw new Error('GNSS snapshot needs a view anchor');
|
||||
const params = new URLSearchParams({
|
||||
lat: String(Math.round(latitude)),
|
||||
lon: String(Math.round(longitude)),
|
||||
});
|
||||
const response = await fetchImpl(`/api/gnss-integrity?${params}`, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const limited = response.status === 429;
|
||||
const retryAfterMs = retryDelay(
|
||||
response.headers.get('retry-after'),
|
||||
now(),
|
||||
);
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
limited
|
||||
? 'GNSS feed rate limited (HTTP 429)'
|
||||
: `GNSS feed unavailable (HTTP ${response.status})`,
|
||||
),
|
||||
{
|
||||
status: response.status,
|
||||
retryable: limited || response.status >= 500,
|
||||
retryAfterMs: retryAfterMs ?? (limited ? 60000 : null),
|
||||
},
|
||||
);
|
||||
}
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
4 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed GNSS integrity snapshot');
|
||||
// The snapshot's age (measured on the proxy's clock) dates every row in
|
||||
// the rolling window; an undated snapshot could only be treated as new,
|
||||
// so it is refused.
|
||||
if (!Number.isFinite(payload.ageMs) || payload.ageMs < 0)
|
||||
throw new Error('GNSS integrity snapshot has no observation time');
|
||||
return {
|
||||
rows: payload.rows,
|
||||
ageMs: payload.ageMs,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt)
|
||||
? payload.fetchedAt
|
||||
: null,
|
||||
stale: payload.stale === true,
|
||||
classifier:
|
||||
typeof payload.classifier === 'string' ? payload.classifier : null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAdsbGnssSource } from './source.js';
|
||||
|
||||
test('the source asks the same-origin proxy for the view anchor', async () => {
|
||||
const calls = [];
|
||||
const source = createAdsbGnssSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(url);
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
return Response.json({
|
||||
fetchedAt: 5,
|
||||
ageMs: 0,
|
||||
classifier: 'gev-nic-nacp-v2',
|
||||
rows: [{ hex: 'abc123' }],
|
||||
});
|
||||
},
|
||||
});
|
||||
const snapshot = await source.getSnapshot(
|
||||
{ latitude: 50.06143, longitude: 19.93658 },
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
assert.deepEqual(calls, ['/api/gnss-integrity?lat=50&lon=20']);
|
||||
assert.deepEqual(snapshot, {
|
||||
rows: [{ hex: 'abc123' }],
|
||||
ageMs: 0,
|
||||
fetchedAt: 5,
|
||||
stale: false,
|
||||
classifier: 'gev-nic-nacp-v2',
|
||||
});
|
||||
});
|
||||
|
||||
test('the source reports stale proxy data and rejects bad input or responses', async () => {
|
||||
const reply = (body, init) => async () => Response.json(body, init);
|
||||
const stale = await createAdsbGnssSource({
|
||||
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs: 90_000, stale: true }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 });
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.ageMs, 90_000, 'a stale reply keeps its observation age');
|
||||
assert.equal(stale.fetchedAt, 7);
|
||||
assert.equal(stale.classifier, null);
|
||||
// Without an observation age the rows could only be dated as new.
|
||||
for (const ageMs of [undefined, null, '7', -1, Number.NaN])
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({
|
||||
fetchImpl: reply({ rows: [], fetchedAt: 7, ageMs }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
||||
/no observation time/,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({}) }).getSnapshot({}),
|
||||
/view anchor/,
|
||||
);
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({
|
||||
fetchImpl: reply({ error: 'x' }, { status: 502 }),
|
||||
}).getSnapshot({ latitude: 1, longitude: 2 }),
|
||||
/HTTP 502/,
|
||||
);
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({ rows: 'no' }) }).getSnapshot({
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
}),
|
||||
/Malformed/,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(
|
||||
createAdsbGnssSource({ fetchImpl: reply({ rows: [] }) }).getSnapshot(
|
||||
{ latitude: 1, longitude: 2 },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('HTTP failures distinguish rate limiting, preserve Retry-After and classify retryability', async () => {
|
||||
const at = Date.UTC(2026, 9, 13, 12);
|
||||
for (const [status, header, delay, retryable] of [
|
||||
[429, '3600', 3600000, true],
|
||||
[429, new Date(at + 3600000).toUTCString(), 3600000, true],
|
||||
[429, null, 60000, true],
|
||||
[429, 'Infinity', 60000, true],
|
||||
[502, '5', 5000, true],
|
||||
[502, null, null, true],
|
||||
[400, null, null, false],
|
||||
]) {
|
||||
const source = createAdsbGnssSource({
|
||||
now: () => at,
|
||||
fetchImpl: async () =>
|
||||
Response.json(
|
||||
{},
|
||||
{ status, headers: header ? { 'Retry-After': header } : {} },
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ latitude: 50, longitude: 20 }),
|
||||
(e) => {
|
||||
assert.equal(e.status, status);
|
||||
assert.equal(e.retryAfterMs, delay);
|
||||
assert.equal(e.retryable, retryable);
|
||||
assert.match(
|
||||
e.message,
|
||||
status === 429 ? /rate limited/ : /feed unavailable/,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,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,
|
||||
@@ -134,6 +135,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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -157,7 +162,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) {
|
||||
@@ -188,6 +195,20 @@ export function createQueries({
|
||||
aircraftClass: tr3bAircraftClass(icao24, text(info?.klass)),
|
||||
originCountry: null,
|
||||
operator: text(info?.operator),
|
||||
squawk: text(info?.squawk),
|
||||
// True while this poll broadcast an emergency (7500/7600/7700 or the
|
||||
// ADS-B status); priority statuses (lifeguard, minfuel) report the kind
|
||||
// without the flag.
|
||||
emergency: info?.emergency?.severity === 'emergency',
|
||||
emergencyKind: text(info?.emergency?.kind),
|
||||
// Provenance, so a summary can say "broadcasting squawk 7700" rather
|
||||
// than "ADS-B reports general emergency" (or flatten both): 'squawk'
|
||||
// when the Mode A code names the status, 'ads-b' for the readsb field.
|
||||
emergencySource: text(info?.emergency?.source),
|
||||
emergencyBroadcast: emergencyBroadcastPhrase(info?.emergency),
|
||||
// Latest known general aircraft-message contact, retained if a poll
|
||||
// has no time. This is not the receipt time of the current status.
|
||||
lastContactAtMs: num(info?.lastContactEpochMs),
|
||||
routeOrigin: null,
|
||||
routeDestination: null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
||||
import { stickyText, stickyNumber } from '../../data/aircraftMeta.js';
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { aircraftEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
GROUND_FLOOR_WARM_MAX_ALT_M,
|
||||
LANDED_MISSING_POLL_LIMIT,
|
||||
@@ -161,6 +162,9 @@ export class MilitaryFlightRecords {
|
||||
null,
|
||||
),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
// Per poll, NOT sticky (mirror of flights): a cleared code clears here.
|
||||
squawk: aircraft.squawk ?? null,
|
||||
emergency: aircraftEmergency(aircraft),
|
||||
onGround,
|
||||
// Round 7: sticky airborne history (see _likelyLanded).
|
||||
wasAirborne: prevMeta?.wasAirborne === true || !onGround,
|
||||
|
||||
@@ -98,3 +98,19 @@ test('military records keep bounded partial retention and release metadata only
|
||||
'remove',
|
||||
);
|
||||
});
|
||||
|
||||
test('military emergency status follows the latest poll instead of sticking', () => {
|
||||
const records = store();
|
||||
const first = records.receive(
|
||||
observation({ squawk: '1234', emergency: 'minfuel' }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(first.meta.squawk, '1234');
|
||||
assert.equal(first.meta.emergency.kind, 'minfuel');
|
||||
assert.equal(first.meta.emergency.severity, 'priority');
|
||||
const next = records.receive(
|
||||
observation({ squawk: '1234', emergency: 'none' }),
|
||||
context(),
|
||||
);
|
||||
assert.equal(next.meta.emergency, null);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
screenProjectedRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import { isEmergency } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
PLANE_MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
MIL_ICON_COLOR,
|
||||
TRACKED_ICON_COLOR,
|
||||
EMERGENCY_ICON_COLOR,
|
||||
MODEL_ALT_CEIL_M,
|
||||
MODEL_MAX_ALL,
|
||||
MODEL_MAX,
|
||||
@@ -159,7 +161,7 @@ export function createRendering({
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.scale = limbScale;
|
||||
bb.scaleByDistance = _cockpitBillboardScaleByDistance();
|
||||
bb.color = MIL_ICON_COLOR.withAlpha(freshnessAlpha);
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(freshnessAlpha);
|
||||
bb.rotation = 0;
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +175,9 @@ export function createRendering({
|
||||
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
||||
bb.scale = _militaryBillboardScale(icao24) * limbScale;
|
||||
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
||||
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
bb.color = _fleetBillboardColor(icao24, meta).withAlpha(
|
||||
cyberSonarBaseAlpha(bb),
|
||||
);
|
||||
}
|
||||
|
||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||
@@ -189,7 +193,17 @@ export function createRendering({
|
||||
|
||||
function _modelColor(icao24) {
|
||||
if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR;
|
||||
return MIL_ICON_COLOR;
|
||||
return _fleetBillboardColor(icao24);
|
||||
}
|
||||
|
||||
/** Fleet (untracked) tint: red while the latest poll broadcast an emergency,
|
||||
* amber otherwise. Cockpit far-contact dots use it too. */
|
||||
|
||||
function _fleetBillboardColor(
|
||||
icao24,
|
||||
info = flightState.records.data.get(icao24),
|
||||
) {
|
||||
return isEmergency(info?.emergency) ? EMERGENCY_ICON_COLOR : MIL_ICON_COLOR;
|
||||
}
|
||||
|
||||
/** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude
|
||||
@@ -965,7 +979,7 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor = MIL_ICON_COLOR;
|
||||
const baseColor = _fleetBillboardColor(icao24, info);
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
@@ -1082,6 +1096,7 @@ export function createRendering({
|
||||
_applyFleetBillboardPresentation,
|
||||
_iconKind,
|
||||
_modelColor,
|
||||
_fleetBillboardColor,
|
||||
_modelRegimeActive,
|
||||
_modelCap,
|
||||
_modelAddDistM,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
@@ -7,12 +8,7 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import {
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
TRACKED_ICON_COLOR,
|
||||
MIL_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||
|
||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||
export function createMilitarySnapshotRenderer({
|
||||
@@ -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
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
MIL_ICON_COLOR,
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
@@ -781,12 +780,16 @@ export function createTracking({
|
||||
bb.width = 20;
|
||||
bb.height = 20;
|
||||
// Ground-aware restore (a plane untracked while taxiing comes back at
|
||||
// ground scale; tint is full amber on the ground and in the air).
|
||||
// ground scale; the fleet tint is amber, or red while broadcasting an
|
||||
// emergency, on the ground and in the air).
|
||||
bb.scale =
|
||||
BILLBOARD_SCALE *
|
||||
(CLASS_SCALE_2D[meta?.klass] || 1) *
|
||||
(meta?.onGround ? GROUND_SCALE : 1);
|
||||
bb.color = MIL_ICON_COLOR;
|
||||
bb.color = parts.rendering._fleetBillboardColor(
|
||||
flightState._trackedIcao,
|
||||
meta,
|
||||
);
|
||||
bb.rotation = flightState._lastTrackedRotation;
|
||||
}
|
||||
flightState._lastCamPoseSig = ''; // force a fleet rotation pass on the next tick
|
||||
|
||||
@@ -27,7 +27,8 @@ const CMR_SEARCH_AFTER = 'CMR-Search-After';
|
||||
/**
|
||||
* Read one product's granules across CMR pages, sending back each response's
|
||||
* `CMR-Search-After` cursor until a short page, a missing cursor or the
|
||||
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
|
||||
* record cap ends the walk. `truncated` means CMR's `hits` exceed the rows
|
||||
* read, or the cap dropped valid granules from the last page read.
|
||||
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
|
||||
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
|
||||
*/
|
||||
@@ -40,6 +41,8 @@ export async function fetchCmrPages({
|
||||
}) {
|
||||
const granules = [];
|
||||
let hits = 0;
|
||||
let readCount = 0;
|
||||
let capped = 0;
|
||||
let cursor = null;
|
||||
for (;;) {
|
||||
const init = { signal };
|
||||
@@ -47,19 +50,27 @@ export async function fetchCmrPages({
|
||||
const response = await fetchImpl(url, init);
|
||||
if (!response?.ok)
|
||||
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
|
||||
const page = parseCmrUmm(await response.json(), product);
|
||||
granules.push(...page.granules);
|
||||
const json = await response.json();
|
||||
const rawCount = Array.isArray(json?.items) ? json.items.length : 0;
|
||||
const page = parseCmrUmm(json, product);
|
||||
readCount += rawCount;
|
||||
const admitted = page.granules.slice(
|
||||
0,
|
||||
Math.max(0, maxRecords - granules.length),
|
||||
);
|
||||
granules.push(...admitted);
|
||||
capped += page.granules.length - admitted.length;
|
||||
hits = Math.max(hits, page.hits);
|
||||
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
|
||||
if (
|
||||
!cursor ||
|
||||
page.granules.length < CMR_PAGE_SIZE ||
|
||||
granules.length >= maxRecords ||
|
||||
granules.length >= hits
|
||||
rawCount < CMR_PAGE_SIZE ||
|
||||
readCount >= maxRecords ||
|
||||
readCount >= hits
|
||||
)
|
||||
break;
|
||||
}
|
||||
return { granules, truncated: hits > granules.length };
|
||||
return { granules, truncated: hits > readCount || capped > 0 };
|
||||
}
|
||||
|
||||
function finiteOrNull(value) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fetchCmrPages } from './catalog.js';
|
||||
const item = (id) => ({
|
||||
meta: { 'concept-id': id },
|
||||
umm: {
|
||||
TemporalExtent: {
|
||||
RangeDateTime: { BeginningDateTime: '2026-09-18T12:00:00Z' },
|
||||
},
|
||||
},
|
||||
});
|
||||
test('discarded rows do not turn a full CMR page into a final page', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
const items =
|
||||
calls === 1
|
||||
? Array.from({ length: 200 }, (_, i) =>
|
||||
i === 0 ? {} : item(String(i)),
|
||||
)
|
||||
: [item('last')];
|
||||
return Response.json(
|
||||
{ hits: 201, items },
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 200);
|
||||
assert.equal(result.granules.at(-1).id, 'last');
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
test('a non-page-multiple limit never returns more admitted records than its budget', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(
|
||||
{
|
||||
hits: 1000,
|
||||
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
|
||||
},
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
test('valid records dropped by the cap mark the result truncated even when every hit was read', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json(
|
||||
{
|
||||
hits: 400,
|
||||
items: Array.from({ length: 200 }, (_, i) => item(`${calls}:${i}`)),
|
||||
},
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, true);
|
||||
});
|
||||
test('malformed rows discarded under the cap leave a fully examined result untruncated', async () => {
|
||||
let calls = 0;
|
||||
const result = await fetchCmrPages({
|
||||
product: 'S30',
|
||||
url: 'fixture',
|
||||
maxRecords: 250,
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
const items = Array.from({ length: 200 }, (_, i) =>
|
||||
calls === 2 && i >= 50 ? {} : item(`${calls}:${i}`),
|
||||
);
|
||||
return Response.json(
|
||||
{ hits: 400, items },
|
||||
{ headers: { 'CMR-Search-After': `cursor-${calls}` } },
|
||||
);
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(result.granules.length, 250);
|
||||
assert.equal(result.truncated, false);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
+1
-13
@@ -1,3 +1,4 @@
|
||||
import { rangeForBoundingSphere } from './data/cameraFraming.js';
|
||||
import { isUnavailableCapability } from './sources/capability.js';
|
||||
import { createOverpassFeatureSource } from './sources/overpassFeatures.js';
|
||||
import { applicationServices } from './services/application.js';
|
||||
@@ -1377,19 +1378,6 @@ function buildingBoundingRadius(bounds) {
|
||||
return Math.hypot(halfHeight, halfWidth, halfDepth) * 1.18;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function buildingPitch(bounds) {
|
||||
if (!bounds) return -25;
|
||||
const footprint = Math.max(bounds.width, bounds.depth);
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseTerrainPoints } from './data/terrainHeightsProxy.js';
|
||||
import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js';
|
||||
|
||||
const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js']
|
||||
@@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => {
|
||||
assert.equal(absent.status, 200);
|
||||
assert.deepEqual(JSON.parse(absent.body), { found: false });
|
||||
});
|
||||
|
||||
for (const preview of [false, true]) {
|
||||
test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => {
|
||||
let calls = 0;
|
||||
const app = fixture('terrainHeightsProxy', { parseTerrainPoints,
|
||||
resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; },
|
||||
}, preview);
|
||||
for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) {
|
||||
assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
}
|
||||
+14
-9
@@ -42,18 +42,23 @@ export function createDefaultPlaceSearch({
|
||||
...(selected
|
||||
? [selected]
|
||||
: [
|
||||
// Google answers through the local proxy, which holds the server
|
||||
// key: its Geocoding web service refuses referrer-restricted keys,
|
||||
// so calling it from here would force the bundled browser key to be
|
||||
// left unrestricted (#363). That key still gates the provider,
|
||||
// because a keyless session must not spend a request to learn it
|
||||
// has no Google: the proxy's "not configured" reads as an
|
||||
// unanswered provider, which stops the chain caching the negative
|
||||
// Photon or Nominatim then produces.
|
||||
createGoogleGeocoder({
|
||||
request(query, { bias, signal }) {
|
||||
const key = resolveApiKey?.();
|
||||
if (!key) return null;
|
||||
const url = new URL(
|
||||
endpoints.geocode ||
|
||||
'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
if (!resolveApiKey?.()) return null;
|
||||
const params = new URLSearchParams({ address: query });
|
||||
if (bias) params.set('bounds', bias);
|
||||
return fetchImpl(
|
||||
`${endpoints.geocode || '/api/google/geocode'}?${params}`,
|
||||
{ signal },
|
||||
);
|
||||
url.searchParams.set('address', query);
|
||||
url.searchParams.set('key', key);
|
||||
if (bias) url.searchParams.set('bounds', bias);
|
||||
return fetchImpl(url.toString(), { signal });
|
||||
},
|
||||
}),
|
||||
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
|
||||
|
||||
+7
-8
@@ -53,7 +53,9 @@ export function createHttpGeospatialProvider({
|
||||
endpoints = {},
|
||||
} = {}) {
|
||||
const urls = {
|
||||
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
// Same-origin like its siblings below: Google's Geocoding web service
|
||||
// refuses referrer-restricted keys, so the key stays on the server (#363).
|
||||
reverse: '/api/google/reverse-geocode',
|
||||
textSearch: '/api/google/text-search',
|
||||
nearby: '/api/google/nearby-places',
|
||||
route: '/api/route',
|
||||
@@ -88,14 +90,11 @@ export function createHttpGeospatialProvider({
|
||||
},
|
||||
routeProfiles: ['foot', 'car', 'bike'],
|
||||
async reverseGeocode(latitude, longitude, options) {
|
||||
const key = resolveApiKey?.();
|
||||
if (resolveApiKey && !key) return null;
|
||||
// The browser key gates the provider without travelling with the
|
||||
// request: the proxy supplies the key Google actually sees.
|
||||
if (resolveApiKey && !resolveApiKey()) return null;
|
||||
return normalizeGoogleReverse(
|
||||
await json(
|
||||
urls.reverse,
|
||||
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
|
||||
options,
|
||||
),
|
||||
await json(urls.reverse, { lat: latitude, lon: longitude }, options),
|
||||
);
|
||||
},
|
||||
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
|
||||
|
||||
@@ -20,7 +20,7 @@ test('standalone geocoding falls back after Google connection, JSON and refusal
|
||||
for (const fail of [() => { throw new Error('offline'); }, () => new Response('invalid json'), () => Response.json({ status: 'REQUEST_DENIED' })]) {
|
||||
const urls = [];
|
||||
const service = createStandalonePlaceSearch({ resolveApiKey: () => 'fixture', fetchImpl: async (url) => {
|
||||
urls.push(url); return url.includes('maps.googleapis.com') ? fail() : hit();
|
||||
urls.push(url); return url.includes('/api/google/geocode') ? fail() : hit();
|
||||
} });
|
||||
const result = await service.geocode('Hanoi');
|
||||
assert.equal(result.place.name, 'Hà Nội');
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApplicationRequestServices } from './requests.js';
|
||||
test('unknown capability replies recover after backoff', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 100_000 });
|
||||
for (const first of [
|
||||
() => Response.json({ wrong: true }),
|
||||
() => new Response('bad', { status: 503 }),
|
||||
]) {
|
||||
let probes = 0;
|
||||
let queries = 0;
|
||||
const services = createApplicationRequestServices({
|
||||
boundaryProbe: { timeoutMs: 1000, retryMs: 1000 },
|
||||
fetchImpl: async (url) => {
|
||||
if (url.endsWith('/status'))
|
||||
return ++probes === 1
|
||||
? first()
|
||||
: Response.json({ configured: false });
|
||||
queries++;
|
||||
return Response.json({ elements: [] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await services.boundaries.query('fixture'), []);
|
||||
assert.deepEqual(await services.boundaries.query('fixture'), []);
|
||||
assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff');
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
(await services.boundaries.query('fixture')).code,
|
||||
'OVERPASS_NOT_CONFIGURED',
|
||||
);
|
||||
assert.equal(
|
||||
(await services.boundaries.query('fixture')).code,
|
||||
'OVERPASS_NOT_CONFIGURED',
|
||||
);
|
||||
assert.equal(probes, 2);
|
||||
assert.equal(queries, 2);
|
||||
}
|
||||
});
|
||||
@@ -66,9 +66,9 @@ export function createApplicationRequestServices({
|
||||
throw new Error(`${label} unavailable (${response.status})`);
|
||||
return response.data;
|
||||
}
|
||||
// Learn once per page whether the server has an Overpass instance, so an
|
||||
// Remember valid capability decisions for the page, so an
|
||||
// unconfigured server is never asked (and never answers with an error).
|
||||
// Discovery is bounded; a failed or timed-out probe is forgotten and not
|
||||
// Discovery is bounded; failed, malformed or timed-out probes are forgotten and not
|
||||
// retried for a short backoff, during which queries go to the server as
|
||||
// they would against an older server without the probe.
|
||||
let boundaryCapability = null;
|
||||
@@ -79,10 +79,13 @@ export function createApplicationRequestServices({
|
||||
boundaryCapability = request(`${urls.boundaries}/status`, {
|
||||
signal: AbortSignal.timeout(boundaryProbe.timeoutMs),
|
||||
}).then(
|
||||
(response) =>
|
||||
response.ok && typeof response.data?.configured === 'boolean'
|
||||
? response.data.configured
|
||||
: null, // an older server: fall back to asking per query
|
||||
(response) => {
|
||||
if (response.ok && typeof response.data?.configured === 'boolean')
|
||||
return response.data.configured;
|
||||
boundaryCapability = null;
|
||||
boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs;
|
||||
return null; // capability unknown: let this query ask the server
|
||||
},
|
||||
(error) => {
|
||||
boundaryCapability = null;
|
||||
if (lifetime?.aborted) throw error;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeSquawk } from '../../data/aircraftEmergency.js';
|
||||
import {
|
||||
admitRecords,
|
||||
cleanText,
|
||||
@@ -30,6 +31,9 @@ export function normalizeOpenSkyAircraft(row) {
|
||||
typeCode: null,
|
||||
registration: null,
|
||||
operator: null,
|
||||
squawk: normalizeSquawk(row[14]),
|
||||
// State vectors carry no ADS-B emergency field; the squawk stands alone.
|
||||
emergency: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +72,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) {
|
||||
typeCode: cleanText(row.t),
|
||||
registration: cleanText(row.r),
|
||||
operator: cleanText(row.ownOp),
|
||||
squawk: normalizeSquawk(row.squawk),
|
||||
emergency: cleanText(row.emergency).toLowerCase() || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +88,13 @@ export function openSkySnapshot(
|
||||
) {
|
||||
const admitted = admitRecords(
|
||||
payload?.states,
|
||||
normalizeOpenSkyAircraft,
|
||||
(row) => {
|
||||
const record = normalizeOpenSkyAircraft(row);
|
||||
const statuses = payload?.emergencyByIcao;
|
||||
if (record && statuses && Object.hasOwn(statuses, record.id))
|
||||
record.emergency = cleanText(statuses[record.id]).toLowerCase() || null;
|
||||
return record;
|
||||
},
|
||||
'OpenSky',
|
||||
);
|
||||
const observedAtMs = epoch(payload?.time, 1000);
|
||||
|
||||
@@ -313,3 +313,23 @@ test('identity lookup validates its response and honors body-parse cancellation'
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('squawks are validated and omitted OpenSky status differs from an explicit ADS-B clear', () => {
|
||||
const squawking = [...aircraft, '7700'];
|
||||
const civil = openSkySnapshot({ states: [squawking] }, { now }).records[0];
|
||||
assert.equal(civil.squawk, '7700');
|
||||
assert.equal(civil.emergency, undefined);
|
||||
assert.equal(openSkySnapshot({ states: [aircraft] }).records[0].squawk, null);
|
||||
const mil = normalizeReadsbAircraft(
|
||||
{ hex: 'abc123', lat: 30, lon: -97, squawk: '7600', emergency: 'NORDO' },
|
||||
now,
|
||||
);
|
||||
assert.equal(mil.squawk, '7600');
|
||||
assert.equal(mil.emergency, 'nordo');
|
||||
const quiet = normalizeReadsbAircraft(
|
||||
{ hex: 'abc123', lat: 30, lon: -97 },
|
||||
now,
|
||||
);
|
||||
assert.equal(quiet.squawk, null);
|
||||
assert.equal(quiet.emergency, null);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js';
|
||||
import { createWfigsPerimeterSource } from '../layers/perimeters/source.js';
|
||||
import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
|
||||
import { createAdsbGnssSource } from '../layers/gnss/source.js';
|
||||
|
||||
/** Construct the existing reference feeds independently of application setup. */
|
||||
export function createReferenceSources() {
|
||||
@@ -8,5 +9,6 @@ export function createReferenceSources() {
|
||||
earthquakes: createUsgsEarthquakeSource(),
|
||||
'fire-perimeters': createWfigsPerimeterSource(),
|
||||
cables: createBundledCableSource(),
|
||||
'gnss-integrity': createAdsbGnssSource(),
|
||||
};
|
||||
}
|
||||
@@ -16,12 +16,15 @@ test('reference factories retain compatibility without starting acquisition or s
|
||||
'earthquakes',
|
||||
'fire-perimeters',
|
||||
'cables',
|
||||
'gnss-integrity',
|
||||
]);
|
||||
assert.notEqual(first.earthquakes, second.earthquakes);
|
||||
assert.notEqual(first['fire-perimeters'], second['fire-perimeters']);
|
||||
assert.notEqual(first.cables, second.cables);
|
||||
assert.notEqual(first['gnss-integrity'], second['gnss-integrity']);
|
||||
assert.equal(typeof first.earthquakes.getSnapshot, 'function');
|
||||
assert.equal(typeof first['fire-perimeters'].getSnapshot, 'function');
|
||||
assert.equal(typeof first.cables.fetch, 'function');
|
||||
assert.equal(typeof first['gnss-integrity'].getSnapshot, 'function');
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
test('data providers have both hooks; credential editing stays development-only', () => {
|
||||
const plugins = localProviderPlugins();
|
||||
assert.ok(plugins.some(({ name }) => name === 'fire-perimeters'));
|
||||
assert.ok(plugins.some(({ name }) => name === 'gnss-integrity'));
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.name === 'gev-key-setup') {
|
||||
assert.equal(plugin.configurePreviewServer, undefined);
|
||||
@@ -103,6 +104,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
['/api/adsbdb/type/invalid', 400],
|
||||
['/api/firms/status', 200],
|
||||
['/api/fire-perimeters/inciweb/publication/invalid', 400],
|
||||
['/api/gnss-integrity?lat=invalid', 400],
|
||||
['/api/terrain/heights?points=invalid', 400],
|
||||
['/api/overpass', 405],
|
||||
['/api/overpass/status', 200],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { composeCatalog, defineTool } from './catalog.js';
|
||||
|
||||
for (const intercept of [false, true]) {
|
||||
test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => {
|
||||
let calls = 0;
|
||||
const controller = new AbortController();
|
||||
const query = defineTool({
|
||||
name: 'fixture_query',
|
||||
kind: 'query',
|
||||
title: 'Fixture',
|
||||
description: 'Offline fixture',
|
||||
inputSchema: { type: 'object' },
|
||||
run: async () => {
|
||||
calls++;
|
||||
return { summary: 'fixture', data: {} };
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: [query],
|
||||
interceptors: intercept
|
||||
? [
|
||||
async (call, next) => {
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
return next(call);
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
if (!intercept) controller.abort();
|
||||
await assert.rejects(
|
||||
catalog.call('fixture_query', {}, { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
const fresh = composeCatalog({ tools: [query] });
|
||||
assert.equal(
|
||||
(
|
||||
await fresh.call(
|
||||
'fixture_query',
|
||||
{},
|
||||
{ signal: new AbortController().signal },
|
||||
)
|
||||
).summary,
|
||||
'fixture',
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
}
|
||||
@@ -178,6 +178,7 @@ export function composeCatalog({
|
||||
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
|
||||
};
|
||||
const execute = async ({ tool, args, signal }) => {
|
||||
signal?.throwIfAborted();
|
||||
const problems = validateValue(tool.inputSchema, args);
|
||||
if (problems.length)
|
||||
throw new ToolError('invalid_arguments', problems.join('; '));
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
compassDivisions,
|
||||
formatAltitudeRulerTick,
|
||||
formatCompassDivision,
|
||||
formatCockpitEmergency,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -138,13 +139,22 @@ export function updateHud(
|
||||
: info.stale
|
||||
? t('STALE FEED')
|
||||
: t('LIVE TRACK');
|
||||
this.aircraftMeta.textContent = t(
|
||||
'{aircraftClass} · {feedState} · COURSE ALIGNED',
|
||||
{
|
||||
aircraftClass:
|
||||
info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL'),
|
||||
feedState,
|
||||
},
|
||||
const fleet =
|
||||
info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL');
|
||||
const emergency = formatCockpitEmergency(info.emergency);
|
||||
this.aircraftMeta.textContent = emergency
|
||||
? t('{fleet} · {emergency} · {feedState}', {
|
||||
fleet,
|
||||
emergency,
|
||||
feedState,
|
||||
})
|
||||
: t('{aircraftClass} · {feedState} · COURSE ALIGNED', {
|
||||
aircraftClass: fleet,
|
||||
feedState,
|
||||
});
|
||||
this.aircraftMeta.classList.toggle(
|
||||
'emergency',
|
||||
info.emergency?.severity === 'emergency',
|
||||
);
|
||||
}
|
||||
this.updateRoute(info);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Frame the complete finite footprint of a session-local file. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
|
||||
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
|
||||
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
|
||||
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
|
||||
const points = [];
|
||||
const add = (position, grounded = false) => {
|
||||
if (
|
||||
!position ||
|
||||
![position.x, position.y, position.z].every(Number.isFinite)
|
||||
)
|
||||
return;
|
||||
const c = Cesium.Cartographic.fromCartesian(position);
|
||||
if (c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
|
||||
points.push([
|
||||
Cesium.Math.toDegrees(c.longitude),
|
||||
Cesium.Math.toDegrees(c.latitude),
|
||||
grounded ? 0 : c.height,
|
||||
]);
|
||||
};
|
||||
if (dataSource?.show === false) return points;
|
||||
for (const entity of dataSource?.importedEntities ??
|
||||
dataSource?.entities?.values ??
|
||||
[]) {
|
||||
if (entity.isShowing === false) continue;
|
||||
add(
|
||||
entity.position?.getValue?.(time),
|
||||
entity.billboard?.heightReference?.getValue?.(time) ===
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
for (const graphic of [entity.polyline, entity.wall])
|
||||
for (const position of graphic?.positions?.getValue?.(time) || [])
|
||||
add(position, graphic.clampToGround?.getValue?.(time) === true);
|
||||
const rings = [entity.polygon?.hierarchy?.getValue?.(time)];
|
||||
while (rings.length) {
|
||||
const ring = rings.pop();
|
||||
for (const position of ring?.positions || [])
|
||||
add(
|
||||
position,
|
||||
entity.polygon?.perPositionHeight?.getValue?.(time) !== true,
|
||||
);
|
||||
for (const hole of ring?.holes || []) rings.push(hole);
|
||||
}
|
||||
const rectangle = entity.rectangle?.coordinates?.getValue?.(time);
|
||||
if (rectangle) {
|
||||
const bounds = Cesium.RectangleGeometry.computeRectangle({
|
||||
rectangle,
|
||||
rotation: entity.rectangle.rotation?.getValue?.(time) || 0,
|
||||
});
|
||||
for (const corner of [
|
||||
'southwest',
|
||||
'southeast',
|
||||
'northwest',
|
||||
'northeast',
|
||||
]) {
|
||||
const c = Cesium.Rectangle[corner](bounds);
|
||||
if (Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
|
||||
points.push([
|
||||
Cesium.Math.toDegrees(c.longitude),
|
||||
Cesium.Math.toDegrees(c.latitude),
|
||||
entity.rectangle.height?.getValue?.(time) || 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Fly synchronously to the footprint; later navigation always owns its camera. */
|
||||
export function flyToDataSource(
|
||||
viewer,
|
||||
dataSource,
|
||||
{ duration = 1.5, ground = null } = {},
|
||||
) {
|
||||
const points = dataSourcePoints(dataSource);
|
||||
if (!points.length) return false;
|
||||
cancelCameraArrival(viewer);
|
||||
const positions = points.map(([lon, lat, height]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(positions);
|
||||
const center =
|
||||
Cesium.Cartographic.fromCartesian(sphere.center) ||
|
||||
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
|
||||
const lat = Cesium.Math.toDegrees(center.latitude),
|
||||
lon = Cesium.Math.toDegrees(center.longitude);
|
||||
const terrain = viewer.scene?.globe?.getHeight?.(center);
|
||||
const cached = ground?.cachedGroundFloor?.(lat, lon);
|
||||
const height =
|
||||
Number.isFinite(terrain) && terrain > 0
|
||||
? terrain
|
||||
: Number.isFinite(cached)
|
||||
? cached
|
||||
: Number.isFinite(terrain)
|
||||
? terrain
|
||||
: 0;
|
||||
ground?.warmGroundFloor?.([{ lat, lon }]);
|
||||
sphere.center = Cesium.Cartesian3.fromRadians(
|
||||
center.longitude,
|
||||
center.latitude,
|
||||
Math.max(0, height, center.height),
|
||||
);
|
||||
sphere.radius = 0;
|
||||
for (const position of positions)
|
||||
sphere.radius = Math.max(
|
||||
sphere.radius,
|
||||
Cesium.Cartesian3.distance(sphere.center, position),
|
||||
);
|
||||
viewer.camera?.flyToBoundingSphere?.(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading || 0,
|
||||
-Math.PI / 2,
|
||||
Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)),
|
||||
),
|
||||
duration,
|
||||
complete: () => guardCameraAboveGround(viewer, { lat, lon }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
|
||||
|
||||
function source() {
|
||||
const ds = new Cesium.CustomDataSource('t');
|
||||
ds.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(-122.4862, 37.7694, -2667),
|
||||
billboard: { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND },
|
||||
});
|
||||
ds.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray([
|
||||
-122.49, 37.77, -122.48, 37.771,
|
||||
]),
|
||||
},
|
||||
});
|
||||
ds.entities.add({
|
||||
polygon: {
|
||||
hierarchy: Cesium.Cartesian3.fromDegreesArray([
|
||||
-122.5, 37.76, -122.49, 37.76, -122.49, 37.765,
|
||||
]),
|
||||
},
|
||||
});
|
||||
return ds;
|
||||
}
|
||||
|
||||
test('points come from positions, lines and polygons, as lon/lat only', () => {
|
||||
const points = dataSourcePoints(source());
|
||||
assert.equal(points.length, 6);
|
||||
assert.ok(Math.abs(points[0][0] + 122.4862) < 1e-6);
|
||||
assert.ok(Math.abs(points[0][1] - 37.7694) < 1e-6);
|
||||
});
|
||||
|
||||
test('the camera frames the footprint at the surface, never a buried center', () => {
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
|
||||
},
|
||||
flyTo: () => calls.push('viewer.flyTo'),
|
||||
};
|
||||
assert.equal(flyToDataSource(viewer, source()), true);
|
||||
const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center);
|
||||
assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`);
|
||||
assert.ok(calls[0].options.offset.range >= 800);
|
||||
assert.equal(
|
||||
typeof calls[0].options.complete,
|
||||
'function',
|
||||
'the ground guard runs on landing',
|
||||
);
|
||||
assert.equal(
|
||||
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
1,
|
||||
'empty sources never start deferred navigation',
|
||||
);
|
||||
});
|
||||
|
||||
test('a very long track is framed without spreading it into a call', () => {
|
||||
// 400k vertices: `push(...line)` throws RangeError (too many arguments)
|
||||
// well below this; the walk keeps a bounded sample and both ends.
|
||||
const n = 400_000;
|
||||
const line = new Array(n);
|
||||
for (let i = 0; i < n; i++)
|
||||
line[i] = Cesium.Cartesian3.fromDegrees(-120 + (i / n) * 2, 40);
|
||||
const ds = new Cesium.CustomDataSource('long');
|
||||
ds.entities.add({ polyline: { positions: line } });
|
||||
ds.entities.add({ position: Cesium.Cartesian3.fromDegrees(-110, 41) });
|
||||
const points = dataSourcePoints(ds);
|
||||
assert.equal(points.length, n + 1, 'every vertex contributes to framing');
|
||||
const lons = points.map(([lon]) => lon);
|
||||
assert.ok(
|
||||
lons.reduce((a, b) => Math.min(a, b), Infinity) < -119.99 &&
|
||||
lons.reduce((a, b) => Math.max(a, b), -Infinity) > -110.01,
|
||||
);
|
||||
assert.ok(lons.some((lon) => Math.abs(lon - -118.000005) < 0.01));
|
||||
});
|
||||
|
||||
test('framing includes a sparse interior outlier, walls and rotated overlays', () => {
|
||||
const ds = new Cesium.CustomDataSource('bounds');
|
||||
const line = Array.from({ length: 200031 }, () =>
|
||||
Cesium.Cartesian3.fromDegrees(0, 0),
|
||||
);
|
||||
line[12345] = Cesium.Cartesian3.fromDegrees(20, 10);
|
||||
ds.entities.add({ polyline: { positions: line } });
|
||||
ds.entities.add({
|
||||
wall: { positions: Cesium.Cartesian3.fromDegreesArray([30, 10, 31, 11]) },
|
||||
});
|
||||
ds.entities.add({
|
||||
rectangle: {
|
||||
coordinates: Cesium.Rectangle.fromDegrees(40, 0, 41, 1),
|
||||
rotation: Math.PI / 4,
|
||||
},
|
||||
});
|
||||
const points = dataSourcePoints(ds);
|
||||
assert.ok(points.some(([lon]) => Math.abs(lon - 20) < 1e-6));
|
||||
assert.ok(points.some(([lon]) => Math.abs(lon - 31) < 1e-6));
|
||||
assert.ok(points.some(([lon]) => lon > 41));
|
||||
});
|
||||
|
||||
test('portrait framing accounts for FOV, high ground and symmetric extents', () => {
|
||||
const ds = new Cesium.CustomDataSource('view');
|
||||
ds.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
|
||||
},
|
||||
});
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
scene: { globe: { getHeight: () => 2600 } },
|
||||
camera: {
|
||||
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 },
|
||||
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
|
||||
},
|
||||
};
|
||||
flyToDataSource(viewer, ds);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600,
|
||||
) < 1e-6,
|
||||
);
|
||||
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2);
|
||||
viewer.camera.frustum.aspectRatio = 16 / 9;
|
||||
flyToDataSource(viewer, ds);
|
||||
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range);
|
||||
const symmetric = {
|
||||
entities: {
|
||||
values: [
|
||||
{ position: { getValue: () => new Cesium.Cartesian3(6378137, 0, 0) } },
|
||||
{ position: { getValue: () => new Cesium.Cartesian3(-6378137, 0, 0) } },
|
||||
],
|
||||
},
|
||||
};
|
||||
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric));
|
||||
assert.ok(Number.isFinite(calls.at(-1).options.offset.range));
|
||||
assert.doesNotThrow(() => calls.at(-1).options.complete());
|
||||
cancelCameraArrival(viewer);
|
||||
});
|
||||
|
||||
test('non-finite positions and hidden features never enter the camera footprint', () => {
|
||||
const position = (p) => ({ position: { getValue: () => p } });
|
||||
const points = dataSourcePoints({
|
||||
entities: {
|
||||
values: [
|
||||
position(new Cesium.Cartesian3(NaN, 1, 2)),
|
||||
{
|
||||
...position(Cesium.Cartesian3.fromDegrees(10, 10)),
|
||||
isShowing: false,
|
||||
},
|
||||
position(Cesium.Cartesian3.fromDegrees(20, 20)),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(points.length, 1);
|
||||
assert.ok(Math.abs(points[0][0] - 20) < 1e-6);
|
||||
});
|
||||
@@ -0,0 +1,493 @@
|
||||
/** Session-local file controls, serial loading and application-owned navigation. */
|
||||
import {
|
||||
GEO_FILE_ACCEPT,
|
||||
GeoImportError,
|
||||
MAX_IMPORTED_FILES,
|
||||
loadGeoFile,
|
||||
} from '../data/geoFileImport.js';
|
||||
import { flyToDataSource } from './flyToDataSource.js';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
|
||||
// One color per file, in import order, from the whiteboard palette.
|
||||
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
|
||||
|
||||
/**
|
||||
* Wire the Import control.
|
||||
* @param {{viewer: object, load?: Function, document?: Document}} deps
|
||||
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
|
||||
* @returns {{destroy: Function, importFiles: Function, list: Function,
|
||||
* setVisible: Function, flyTo: Function, remove: Function,
|
||||
* diagnostics: Function}|null}
|
||||
*/
|
||||
export function initGeoFileImport({
|
||||
viewer,
|
||||
load = loadGeoFile,
|
||||
document: doc = globalThis.document,
|
||||
flyToSource = flyToDataSource,
|
||||
navigation = null,
|
||||
ground = null,
|
||||
subscribeMapSource = null,
|
||||
}) {
|
||||
const button = doc?.getElementById('geo-import-button');
|
||||
const input = doc?.getElementById('geo-import-input');
|
||||
const listEl = doc?.getElementById('geo-import-list');
|
||||
const hint = doc?.getElementById('geo-import-hint');
|
||||
if (!viewer || !button || !input || !listEl) return null;
|
||||
|
||||
const imports = [];
|
||||
const listeners = [];
|
||||
let queue = Promise.resolve();
|
||||
let seq = 0;
|
||||
let colorAt = 0;
|
||||
let destroyed = false;
|
||||
let dragDepth = 0;
|
||||
let cameraIntent = 0;
|
||||
const lifetime = new AbortController();
|
||||
let removeMapSource;
|
||||
|
||||
const listen = (target, type, listener, options) => {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(type, listener, options);
|
||||
listeners.push([target, type, listener, options]);
|
||||
};
|
||||
const setHint = (text) => {
|
||||
if (hint) hint.textContent = text || '';
|
||||
};
|
||||
const render = () => viewer.scene?.requestRender?.();
|
||||
let groundTimer,
|
||||
groundQueued = false,
|
||||
groundAgain = false;
|
||||
const groundRemovers = [];
|
||||
const scheduleGround = () => {
|
||||
if (destroyed || !imports.length) return;
|
||||
if (groundQueued) {
|
||||
groundAgain = true;
|
||||
return;
|
||||
}
|
||||
clearTimeout(groundTimer);
|
||||
groundTimer = setTimeout(() => {
|
||||
groundQueued = true;
|
||||
queue = queue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
try {
|
||||
for (const item of imports) {
|
||||
if (destroyed) return;
|
||||
if (item.visible) await item.refreshGround?.(lifetime.signal);
|
||||
}
|
||||
render();
|
||||
} catch {
|
||||
if (!destroyed)
|
||||
setHint(
|
||||
t('Ground heights could not be refreshed. Try the file again when the map has loaded.'),
|
||||
);
|
||||
} finally {
|
||||
groundQueued = false;
|
||||
if (groundAgain) {
|
||||
groundAgain = false;
|
||||
scheduleGround();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
removeMapSource = subscribeMapSource?.(scheduleGround);
|
||||
const ownEvent = (event, listener) => {
|
||||
const remove = event?.addEventListener?.(listener);
|
||||
if (remove) groundRemovers.push(remove);
|
||||
};
|
||||
ownEvent(viewer.camera?.moveEnd, scheduleGround);
|
||||
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
|
||||
if (!pending) scheduleGround();
|
||||
});
|
||||
|
||||
/** Which row button has focus, so a re-render can give it back. */
|
||||
function focusedAction() {
|
||||
const active = doc.activeElement;
|
||||
if (!active || !listEl.contains?.(active)) return null;
|
||||
const row = active.closest?.('[data-import-id]');
|
||||
return row
|
||||
? { id: row.dataset.importId, action: active.dataset?.action }
|
||||
: null;
|
||||
}
|
||||
|
||||
function restoreFocus(was) {
|
||||
if (!was) return;
|
||||
const rows = [...listEl.querySelectorAll('[data-import-id]')];
|
||||
let row = rows.find((r) => r.dataset.importId === was.id);
|
||||
// The row is gone (removed): the next row's same control, else the
|
||||
// previous row's, else the Import button.
|
||||
if (!row && was.index != null)
|
||||
row = rows[Math.min(was.index, rows.length - 1)];
|
||||
const target =
|
||||
row?.querySelector?.(`button[data-action="${was.action}"]`) ||
|
||||
(row ? row.querySelector?.('button') : button);
|
||||
target?.focus?.();
|
||||
}
|
||||
|
||||
function renderList(keep = focusedAction()) {
|
||||
listEl.replaceChildren(
|
||||
...imports.map((item) => {
|
||||
const row = doc.createElement('li');
|
||||
row.className = 'geo-import-row';
|
||||
row.dataset.importId = item.id;
|
||||
const swatch = doc.createElement('span');
|
||||
// KML and KMZ draw with their own styles; their swatch says so
|
||||
// instead of claiming a color the globe does not show.
|
||||
swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`;
|
||||
if (!item.ownStyles) swatch.style.background = item.color;
|
||||
const name = doc.createElement('span');
|
||||
name.className = 'geo-import-name';
|
||||
// textContent, never markup: the name comes from the file system.
|
||||
name.textContent = item.name;
|
||||
const objects = tn(
|
||||
item.entityCount,
|
||||
'{count} map object',
|
||||
'{count} map objects',
|
||||
{ count: formatNumber(item.entityCount) },
|
||||
);
|
||||
name.title = item.ownStyles
|
||||
? t('{name} — {format}, {objects}, drawn with its own styles', {
|
||||
name: item.name,
|
||||
format: item.format.toUpperCase(),
|
||||
objects,
|
||||
})
|
||||
: t('{name} — {format}, {objects}', {
|
||||
name: item.name,
|
||||
format: item.format.toUpperCase(),
|
||||
objects,
|
||||
});
|
||||
const actions = [
|
||||
[
|
||||
item.visible ? 'visibility' : 'visibility_off',
|
||||
item.visible
|
||||
? t('Hide {name}', { name: item.name })
|
||||
: t('Show {name}', { name: item.name }),
|
||||
'toggle',
|
||||
],
|
||||
['my_location', t('Fly to {name}', { name: item.name }), 'fly'],
|
||||
['close', t('Remove {name}', { name: item.name }), 'remove'],
|
||||
].map(([icon, label, action]) => {
|
||||
const btn = doc.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'geo-import-action';
|
||||
btn.dataset.action = action;
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
if (action === 'toggle')
|
||||
btn.setAttribute('aria-pressed', String(item.visible));
|
||||
const glyph = doc.createElement('span');
|
||||
glyph.className = 'material-symbols-outlined';
|
||||
glyph.setAttribute('aria-hidden', 'true');
|
||||
glyph.textContent = icon;
|
||||
btn.append(glyph);
|
||||
return btn;
|
||||
});
|
||||
row.append(swatch, name, ...actions);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
listEl.classList.toggle('visible', imports.length > 0);
|
||||
restoreFocus(keep);
|
||||
}
|
||||
|
||||
const find = (id) => imports.find((item) => item.id === id) || null;
|
||||
|
||||
/** Show or hide one imported file. */
|
||||
function setVisible(id, visible) {
|
||||
const item = find(id);
|
||||
if (!item) return false;
|
||||
item.visible = Boolean(visible);
|
||||
item.dataSource.show = item.visible;
|
||||
if (item.visible) scheduleGround();
|
||||
renderList();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not viewer.flyTo: it frames ground-clamped points underground until the
|
||||
// destination's terrain has loaded (see ./flyToDataSource.js).
|
||||
const fly = (item) => {
|
||||
if (!item.visible) setVisible(item.id, true);
|
||||
flyToSource(viewer, item.dataSource, { ground });
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to one imported file: an immediate navigation, so Cockpit
|
||||
* can refuse it and the current follow/track owner is released first.
|
||||
*/
|
||||
function flyTo(id) {
|
||||
const item = find(id);
|
||||
if (!item) return false;
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('file', () => fly(item)) !== false
|
||||
);
|
||||
return fly(item);
|
||||
}
|
||||
|
||||
/** Stamp a deferred fly for an import batch; false when refused. */
|
||||
const beginAutoFly = () =>
|
||||
typeof navigation?.beginDeferredLocationNavigation === 'function'
|
||||
? navigation.beginDeferredLocationNavigation()
|
||||
: null;
|
||||
/** Re-assert the stamp right before flying; false when superseded. */
|
||||
const autoFlyStillOwns = (generation) => {
|
||||
if (generation === false) return false;
|
||||
if (generation === null) return true;
|
||||
return typeof navigation?.reassertDeferredLocationNavigation === 'function'
|
||||
? navigation.reassertDeferredLocationNavigation(generation) === true
|
||||
: true;
|
||||
};
|
||||
|
||||
/** Remove one imported file from the globe and the list. */
|
||||
function remove(id) {
|
||||
const at = imports.findIndex((item) => item.id === id);
|
||||
if (at < 0) return false;
|
||||
const keep = focusedAction();
|
||||
if (keep) keep.index = at;
|
||||
const [item] = imports.splice(at, 1);
|
||||
viewer.dataSources?.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
renderList(keep);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import files, one after another. Returns one outcome per file.
|
||||
* @param {Iterable<File>} files
|
||||
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
|
||||
* removed?: number, error?: string}>>}
|
||||
*/
|
||||
function importFiles(files) {
|
||||
const list = [...(files || [])];
|
||||
if (destroyed || !list.length) return Promise.resolve([]);
|
||||
// Capture the user's intent when the files are chosen, including time
|
||||
// spent waiting for an earlier batch. Later navigation must still win.
|
||||
const generation = beginAutoFly();
|
||||
const intent = ++cameraIntent;
|
||||
const run = queue.then(() => importBatch(list, generation, intent));
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function importBatch(list, generation, intent) {
|
||||
const outcomes = [];
|
||||
if (destroyed || !list.length) return outcomes;
|
||||
for (const file of list) {
|
||||
if (destroyed) break;
|
||||
const fileName = String(file?.name || 'file');
|
||||
if (imports.length >= MAX_IMPORTED_FILES) {
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: false,
|
||||
error: t('At most {max} files at once — remove one first.', {
|
||||
max: MAX_IMPORTED_FILES,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setHint(t('Reading {file}…', { file: fileName }));
|
||||
const color = COLORS[colorAt % COLORS.length];
|
||||
let loaded;
|
||||
try {
|
||||
loaded = await load(file, { viewer, color, signal: lifetime.signal });
|
||||
} catch (error) {
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: false,
|
||||
error:
|
||||
error instanceof GeoImportError || error?.name === 'GeoImportError'
|
||||
? error.message
|
||||
: 'That file could not be read.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (destroyed) {
|
||||
loaded.revoke?.();
|
||||
break;
|
||||
}
|
||||
colorAt += 1;
|
||||
seq += 1;
|
||||
const item = {
|
||||
id: `import-${seq}`,
|
||||
name: loaded.name || fileName,
|
||||
format: loaded.format,
|
||||
ownStyles: loaded.format === 'kml' || loaded.format === 'kmz',
|
||||
entityCount: loaded.entityCount,
|
||||
removed: loaded.removed || 0,
|
||||
color,
|
||||
visible: true,
|
||||
dataSource: loaded.dataSource,
|
||||
revoke: loaded.revoke,
|
||||
refreshGround: loaded.refreshGround,
|
||||
};
|
||||
await viewer.dataSources.add(item.dataSource);
|
||||
if (destroyed) {
|
||||
viewer.dataSources.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
break;
|
||||
}
|
||||
loaded.mount?.();
|
||||
imports.push(item);
|
||||
renderList();
|
||||
render();
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: true,
|
||||
id: item.id,
|
||||
removed: item.removed,
|
||||
});
|
||||
// Fly to the first file of a batch that lands, so a drop shows its
|
||||
// result, unless something else took the camera meanwhile.
|
||||
if (
|
||||
outcomes.filter((o) => o.ok).length === 1 &&
|
||||
intent === cameraIntent &&
|
||||
autoFlyStillOwns(generation)
|
||||
)
|
||||
fly(item);
|
||||
}
|
||||
if (!destroyed) setHint(summarize(outcomes));
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
// ---- controls ----------------------------------------------------------
|
||||
input.accept = GEO_FILE_ACCEPT;
|
||||
listen(button, 'click', () => input.click());
|
||||
listen(input, 'change', () => {
|
||||
const files = [...(input.files || [])];
|
||||
// Clear the selection so choosing the same file again fires `change`.
|
||||
input.value = '';
|
||||
if (files.length) void importFiles(files);
|
||||
});
|
||||
listen(listEl, 'click', (event) => {
|
||||
const btn = event.target?.closest?.('button[data-action]');
|
||||
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
|
||||
if (!btn || !id) return;
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'toggle') setVisible(id, !find(id)?.visible);
|
||||
else if (action === 'fly') flyTo(id);
|
||||
else if (action === 'remove') remove(id);
|
||||
});
|
||||
|
||||
// ---- drop onto the globe ----------------------------------------------
|
||||
const container = viewer.container;
|
||||
const gesture = () => {
|
||||
cameraIntent += 1;
|
||||
};
|
||||
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
|
||||
passive: true,
|
||||
});
|
||||
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
|
||||
passive: true,
|
||||
});
|
||||
const hasFiles = (event) =>
|
||||
[...(event.dataTransfer?.types || [])].includes('Files');
|
||||
const setDragging = (on) =>
|
||||
doc.body?.classList?.toggle('gev-geo-import-drag', on);
|
||||
listen(container, 'dragenter', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
setDragging(true);
|
||||
});
|
||||
listen(doc, 'dragover', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
listen(container, 'dragleave', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (!dragDepth) setDragging(false);
|
||||
});
|
||||
listen(doc, 'drop', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setDragging(false);
|
||||
const files = [...(event.dataTransfer?.files || [])];
|
||||
if (files.length) void importFiles(files);
|
||||
});
|
||||
|
||||
renderList();
|
||||
|
||||
const handle = {
|
||||
importFiles,
|
||||
list: () =>
|
||||
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
|
||||
id,
|
||||
name,
|
||||
format,
|
||||
entityCount,
|
||||
removed,
|
||||
visible,
|
||||
})),
|
||||
setVisible,
|
||||
flyTo,
|
||||
remove,
|
||||
diagnostics: () => ({
|
||||
files: imports.length,
|
||||
entities: imports.reduce((n, item) => n + item.entityCount, 0),
|
||||
listeners: listeners.length,
|
||||
destroyed,
|
||||
}),
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
lifetime.abort();
|
||||
removeMapSource?.();
|
||||
clearTimeout(groundTimer);
|
||||
for (const remove of groundRemovers) remove();
|
||||
for (const item of imports.splice(0)) {
|
||||
viewer.dataSources?.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
}
|
||||
for (const [target, type, listener, options] of listeners.splice(0))
|
||||
target.removeEventListener(type, listener, options);
|
||||
setDragging(false);
|
||||
listEl.replaceChildren();
|
||||
listEl.classList.remove('visible');
|
||||
setHint('');
|
||||
if (globalThis.window?.__gevGeoImport === handle)
|
||||
delete globalThis.window.__gevGeoImport;
|
||||
},
|
||||
};
|
||||
if (globalThis.window) globalThis.window.__gevGeoImport = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the hint after an import batch.
|
||||
* @param {Array<{name: string, ok: boolean, error?: string, removed?: number}>} outcomes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function summarize(outcomes) {
|
||||
const failed = outcomes.filter((o) => !o.ok);
|
||||
const added = outcomes.length - failed.length;
|
||||
const parts = [];
|
||||
if (added)
|
||||
parts.push(
|
||||
tn(added, 'Added {count} file.', 'Added {count} files.', {
|
||||
count: formatNumber(added),
|
||||
}),
|
||||
);
|
||||
if (failed.length === 1)
|
||||
parts.push(t('{name}: {error}', failed[0]));
|
||||
else if (failed.length > 1)
|
||||
parts.push(
|
||||
t('{count} files could not be added ({name}: {error})', {
|
||||
count: formatNumber(failed.length),
|
||||
name: failed[0].name,
|
||||
error: failed[0].error,
|
||||
}),
|
||||
);
|
||||
const stripped = outcomes.reduce(
|
||||
(n, o) => n + (o.ok ? o.removed || 0 : 0),
|
||||
0,
|
||||
);
|
||||
if (added && stripped) parts.push(t('Some KML content was left out.'));
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
|
||||
// fake DOM and viewer doubles with an injected loader, and its observable
|
||||
// effects are asserted — data sources added and removed, list rows, the hint,
|
||||
// drop handling, the file limit, and what destroy() gives back. Real parsing
|
||||
// and rendering run in `scripts/qa-geo-import.mjs`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { initGeoFileImport, summarize } from './geoFileImport.js';
|
||||
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor() {
|
||||
this.names = new Set();
|
||||
}
|
||||
add(...n) {
|
||||
n.forEach((x) => this.names.add(x));
|
||||
}
|
||||
remove(...n) {
|
||||
n.forEach((x) => this.names.delete(x));
|
||||
}
|
||||
contains(n) {
|
||||
return this.names.has(n);
|
||||
}
|
||||
toggle(n, force) {
|
||||
const on = force === undefined ? !this.names.has(n) : Boolean(force);
|
||||
if (on) this.names.add(n);
|
||||
else this.names.delete(n);
|
||||
return on;
|
||||
}
|
||||
}
|
||||
|
||||
// The one focused element across every fake document (tests run one at a time).
|
||||
const focus = { active: null };
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div', id = '') {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.id = id;
|
||||
this.classList = new FakeClassList();
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this.attributes = new Map();
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.listeners = [];
|
||||
this.textContent = '';
|
||||
this.value = '';
|
||||
this.clicks = 0;
|
||||
}
|
||||
set className(v) {
|
||||
this.classList = new FakeClassList();
|
||||
String(v)
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.forEach((n) => this.classList.add(n));
|
||||
}
|
||||
setAttribute(n, v) {
|
||||
this.attributes.set(n, String(v));
|
||||
}
|
||||
getAttribute(n) {
|
||||
return this.attributes.get(n) ?? null;
|
||||
}
|
||||
append(...kids) {
|
||||
for (const k of kids) {
|
||||
k.parent = this;
|
||||
this.children.push(k);
|
||||
}
|
||||
}
|
||||
replaceChildren(...kids) {
|
||||
this.children = [];
|
||||
this.append(...kids);
|
||||
}
|
||||
addEventListener(type, listener, options) {
|
||||
this.listeners.push({ type, listener, options });
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
const at = this.listeners.findIndex(
|
||||
(l) => l.type === type && l.listener === listener,
|
||||
);
|
||||
if (at >= 0) this.listeners.splice(at, 1);
|
||||
}
|
||||
emit(type, event = {}) {
|
||||
for (const { listener } of this.listeners.filter((l) => l.type === type))
|
||||
listener({
|
||||
target: this,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
...event,
|
||||
});
|
||||
}
|
||||
click() {
|
||||
this.clicks += 1;
|
||||
this.emit('click');
|
||||
}
|
||||
focus() {
|
||||
focus.active = this;
|
||||
}
|
||||
contains(el) {
|
||||
for (let at = el; at; at = at.parent) if (at === this) return true;
|
||||
return false;
|
||||
}
|
||||
*walk() {
|
||||
for (const kid of this.children) {
|
||||
yield kid;
|
||||
yield* kid.walk();
|
||||
}
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
|
||||
return [...this.walk()].filter((el) => el.dataset.importId);
|
||||
}
|
||||
querySelector(selector) {
|
||||
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
|
||||
for (const el of this.walk()) {
|
||||
if (el.tagName !== 'BUTTON') continue;
|
||||
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
closest(selector) {
|
||||
for (let el = this; el; el = el.parent) {
|
||||
if (
|
||||
selector === 'button[data-action]' &&
|
||||
el.tagName === 'BUTTON' &&
|
||||
el.dataset.action
|
||||
)
|
||||
return el;
|
||||
if (selector === '[data-import-id]' && el.dataset.importId) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
|
||||
focus.active = null;
|
||||
const els = {
|
||||
'geo-import-button': new FakeElement('button', 'geo-import-button'),
|
||||
'geo-import-input': new FakeElement('input', 'geo-import-input'),
|
||||
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
|
||||
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
|
||||
};
|
||||
const document = {
|
||||
body: new FakeElement('body'),
|
||||
getElementById: (id) => els[id] ?? null,
|
||||
createElement: (tag) => new FakeElement(tag),
|
||||
get activeElement() {
|
||||
return focus.active;
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(document, new FakeElement('document'));
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const flights = [];
|
||||
let renders = 0;
|
||||
const viewer = {
|
||||
container: new FakeElement('div', 'cesiumContainer'),
|
||||
scene: { requestRender: () => (renders += 1) },
|
||||
dataSources: {
|
||||
add: async (ds) => {
|
||||
added.push(ds);
|
||||
return ds;
|
||||
},
|
||||
remove: (ds, destroy) => {
|
||||
removed.push({ ds, destroy });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
flyTo: (target) => flights.push(target),
|
||||
};
|
||||
const revoked = [];
|
||||
const loads = [];
|
||||
const defaultLoad = async (file, opts) => {
|
||||
loads.push({ file, opts });
|
||||
if (file.fail) throw new GeoImportError(file.fail);
|
||||
if (file.crash) throw new Error('boom');
|
||||
return {
|
||||
dataSource: { show: true },
|
||||
name: file.name,
|
||||
format: 'geojson',
|
||||
entityCount: 3,
|
||||
removed: file.removed || 0,
|
||||
revoke: () => revoked.push(file.name),
|
||||
};
|
||||
};
|
||||
const tool = initGeoFileImport({
|
||||
viewer,
|
||||
load: load || defaultLoad,
|
||||
document,
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(subscribeMapSource ? { subscribeMapSource } : {}),
|
||||
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
|
||||
});
|
||||
return {
|
||||
tool,
|
||||
els,
|
||||
document,
|
||||
viewer,
|
||||
added,
|
||||
removed,
|
||||
flights,
|
||||
revoked,
|
||||
loads,
|
||||
renders: () => renders,
|
||||
};
|
||||
}
|
||||
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
|
||||
const rows = (els) => els['geo-import-list'].children;
|
||||
const action = (row, name) =>
|
||||
row.children.find((c) => c.dataset.action === name);
|
||||
|
||||
test('returns null without its markup or viewer', () => {
|
||||
const document = { getElementById: () => null };
|
||||
assert.equal(initGeoFileImport({ viewer: {}, document }), null);
|
||||
assert.equal(
|
||||
initGeoFileImport({
|
||||
viewer: null,
|
||||
document: { getElementById: () => new FakeElement() },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the Import button opens the picker and a pick imports each file', async () => {
|
||||
const t = setup();
|
||||
t.els['geo-import-button'].click();
|
||||
assert.equal(t.els['geo-import-input'].clicks, 1);
|
||||
assert.equal(
|
||||
t.els['geo-import-input'].accept,
|
||||
'.geojson,.json,.kml,.kmz,.gpx',
|
||||
);
|
||||
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
|
||||
assert.deepEqual(
|
||||
out.map((o) => o.ok),
|
||||
[true, true],
|
||||
);
|
||||
assert.equal(t.added.length, 2);
|
||||
assert.notEqual(
|
||||
t.loads[0].opts.color,
|
||||
t.loads[1].opts.color,
|
||||
'each file gets its own color',
|
||||
);
|
||||
assert.equal(rows(t.els).length, 2);
|
||||
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
|
||||
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
|
||||
assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
|
||||
assert.deepEqual(
|
||||
t.tool.list().map((f) => f.name),
|
||||
['a.geojson', 'b.kml'],
|
||||
);
|
||||
});
|
||||
|
||||
test('file names are text, never markup', async () => {
|
||||
const t = setup();
|
||||
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
|
||||
const name = rows(t.els)[0].children.find((c) =>
|
||||
c.classList.contains('geo-import-name'),
|
||||
);
|
||||
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
|
||||
assert.equal(name.innerHTML, undefined);
|
||||
});
|
||||
|
||||
test('row buttons hide, show, fly to and remove a file', async () => {
|
||||
const t = setup();
|
||||
await t.tool.importFiles([file('a.gpx')]);
|
||||
const [{ id }] = t.tool.list();
|
||||
const ds = t.added[0];
|
||||
// the click lands on the glyph inside the button, as in a browser
|
||||
const glyphClick = (name) =>
|
||||
t.els['geo-import-list'].emit('click', {
|
||||
target: action(rows(t.els)[0], name).children[0],
|
||||
});
|
||||
assert.equal(ds.show, true);
|
||||
glyphClick('toggle');
|
||||
assert.equal(ds.show, false);
|
||||
assert.equal(t.tool.list()[0].visible, false);
|
||||
assert.equal(
|
||||
action(rows(t.els)[0], 'toggle').getAttribute('aria-pressed'),
|
||||
'false',
|
||||
);
|
||||
glyphClick('fly');
|
||||
assert.equal(ds.show, true, 'flying to a hidden file shows it');
|
||||
assert.equal(t.flights.at(-1), ds);
|
||||
glyphClick('remove');
|
||||
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
|
||||
assert.deepEqual(t.revoked, ['a.gpx']);
|
||||
assert.equal(t.tool.list().length, 0);
|
||||
assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
|
||||
assert.equal(t.tool.remove(id), false);
|
||||
});
|
||||
|
||||
test('a refused file says why and does not stop the rest of the batch', async () => {
|
||||
const t = setup();
|
||||
const out = await t.tool.importFiles([
|
||||
file('bad.kml', { fail: 'That KML file is not valid XML.' }),
|
||||
file('odd.gpx', { crash: true }),
|
||||
file('good.geojson'),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
out.map((o) => o.ok),
|
||||
[false, false, true],
|
||||
);
|
||||
assert.equal(out[0].error, 'That KML file is not valid XML.');
|
||||
assert.match(out[1].error, /could not be read\./);
|
||||
assert.match(
|
||||
t.els['geo-import-hint'].textContent,
|
||||
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
|
||||
);
|
||||
assert.equal(t.tool.list().length, 1);
|
||||
});
|
||||
|
||||
test('the number of files on the globe is bounded', async () => {
|
||||
const t = setup();
|
||||
const many = Array.from({ length: MAX_IMPORTED_FILES + 2 }, (_, i) =>
|
||||
file(`f${i}.geojson`),
|
||||
);
|
||||
const out = await t.tool.importFiles(many);
|
||||
assert.equal(out.filter((o) => o.ok).length, MAX_IMPORTED_FILES);
|
||||
assert.match(out.at(-1).error, /At most 12 files/);
|
||||
assert.equal(
|
||||
t.loads.length,
|
||||
MAX_IMPORTED_FILES,
|
||||
'files over the limit are not even read',
|
||||
);
|
||||
});
|
||||
|
||||
test('dropping files on the globe imports them; other drags are left alone', async () => {
|
||||
const t = setup();
|
||||
const c = t.viewer.container;
|
||||
const files = { types: ['Files'], files: [file('drop.kmz')] };
|
||||
let ev = { dataTransfer: { types: ['text/plain'] } };
|
||||
t.document.emit('dragover', ev);
|
||||
c.emit('dragenter', { dataTransfer: files });
|
||||
assert.ok(t.document.body.classList.contains('gev-geo-import-drag'));
|
||||
c.emit('dragleave', { dataTransfer: files });
|
||||
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
|
||||
c.emit('dragenter', { dataTransfer: files });
|
||||
t.document.emit('drop', { dataTransfer: files });
|
||||
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.deepEqual(
|
||||
t.tool.list().map((f) => f.name),
|
||||
['drop.kmz'],
|
||||
);
|
||||
});
|
||||
|
||||
test('stripped KML content is mentioned once, for the batch that had it', () => {
|
||||
assert.equal(
|
||||
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
|
||||
'Added 1 file. Some KML content was left out.',
|
||||
);
|
||||
assert.equal(
|
||||
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
|
||||
'Added 1 file.',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy removes every file and listener and the window handle', async () => {
|
||||
globalThis.window = {};
|
||||
try {
|
||||
const t = setup();
|
||||
assert.equal(globalThis.window.__gevGeoImport, t.tool);
|
||||
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
|
||||
t.tool.destroy();
|
||||
assert.equal(t.removed.length, 2);
|
||||
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
|
||||
for (const el of [
|
||||
t.viewer.container,
|
||||
t.els['geo-import-button'],
|
||||
t.els['geo-import-input'],
|
||||
t.els['geo-import-list'],
|
||||
])
|
||||
assert.equal(el.listeners.length, 0);
|
||||
assert.equal(rows(t.els).length, 0);
|
||||
assert.equal(globalThis.window.__gevGeoImport, undefined);
|
||||
assert.equal(t.tool.diagnostics().destroyed, true);
|
||||
} finally {
|
||||
delete globalThis.window;
|
||||
}
|
||||
});
|
||||
|
||||
test('a file that finishes loading after destroy is released, not added', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => (release = r));
|
||||
const revoked = [];
|
||||
const t = setup({
|
||||
load: async () => {
|
||||
await gate;
|
||||
return {
|
||||
dataSource: {},
|
||||
name: 'late',
|
||||
format: 'kml',
|
||||
entityCount: 1,
|
||||
removed: 0,
|
||||
revoke: () => revoked.push('late'),
|
||||
};
|
||||
},
|
||||
});
|
||||
const pending = t.tool.importFiles([file('late.kml')]);
|
||||
// Imports are queued: let the batch start loading before destroy lands.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
t.tool.destroy();
|
||||
release();
|
||||
await pending;
|
||||
assert.equal(t.added.length, 0);
|
||||
assert.deepEqual(revoked, ['late']);
|
||||
});
|
||||
|
||||
test('the application composition wires the import and owns its teardown', () => {
|
||||
const tools = fs.readFileSync(
|
||||
new URL('../app/tools.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const drawAt = tools.indexOf(
|
||||
'const drawTool = initDrawTool({ viewer, annotations });',
|
||||
);
|
||||
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
|
||||
assert.ok(importAt > drawAt && drawAt >= 0);
|
||||
// Fly-to goes through the application shell's navigation policy.
|
||||
assert.match(
|
||||
tools.slice(importAt, importAt + 200),
|
||||
/navigation: styleManager/,
|
||||
);
|
||||
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
|
||||
const markup = fs.readFileSync(
|
||||
new URL('./templates/display-controls.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
for (const id of [
|
||||
'geo-import-button',
|
||||
'geo-import-input',
|
||||
'geo-import-list',
|
||||
'geo-import-hint',
|
||||
])
|
||||
assert.ok(
|
||||
markup.includes(`id="${id}"`),
|
||||
`${id} missing from the DISPLAY template`,
|
||||
);
|
||||
});
|
||||
|
||||
test('two imports at once never pass the file cap: batches run one after another', async () => {
|
||||
const t = setup();
|
||||
const first = t.tool.importFiles(
|
||||
Array.from({ length: 8 }, (_, i) => file(`a${i}.geojson`)),
|
||||
);
|
||||
const second = t.tool.importFiles(
|
||||
Array.from({ length: 8 }, (_, i) => file(`b${i}.geojson`)),
|
||||
);
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
assert.equal(t.tool.list().length, MAX_IMPORTED_FILES);
|
||||
assert.equal(a.filter((o) => o.ok).length, 8);
|
||||
assert.equal(b.filter((o) => o.ok).length, MAX_IMPORTED_FILES - 8);
|
||||
assert.equal(b.filter((o) => !o.ok).length, 16 - MAX_IMPORTED_FILES);
|
||||
// The second batch's loads started only after the first batch finished.
|
||||
assert.deepEqual(
|
||||
t.loads.map((l) => l.file.name.slice(0, 1)).join(''),
|
||||
'a'.repeat(8) + 'b'.repeat(MAX_IMPORTED_FILES - 8),
|
||||
);
|
||||
});
|
||||
|
||||
function navigationDouble({ immediate = true, reassert = true } = {}) {
|
||||
const calls = [];
|
||||
let generation = 0;
|
||||
return {
|
||||
calls,
|
||||
runImmediateNavigation(noun, navigate) {
|
||||
calls.push(['immediate', noun]);
|
||||
return immediate ? navigate(++generation) : false;
|
||||
},
|
||||
beginDeferredLocationNavigation() {
|
||||
calls.push(['begin']);
|
||||
return ++generation;
|
||||
},
|
||||
reassertDeferredLocationNavigation(g) {
|
||||
calls.push(['reassert', g]);
|
||||
return reassert;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a direct camera gesture cancels the fly-to of a file still being read', async () => {
|
||||
for (const gesture of ['pointerdown', 'wheel']) {
|
||||
let release;
|
||||
const t = setup({
|
||||
load: async () => {
|
||||
await new Promise((r) => (release = r));
|
||||
return {
|
||||
name: 'slow.geojson',
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
dataSource: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = t.tool.importFiles([file('slow.geojson')]);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
t.viewer.container.emit(gesture);
|
||||
release();
|
||||
await result;
|
||||
assert.equal(t.added.length, 1);
|
||||
assert.equal(t.flights.length, 0);
|
||||
t.tool.destroy();
|
||||
assert.equal(t.document.listeners.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('dropping on panel controls is accepted and file drags prevent browser navigation', async () => {
|
||||
const t = setup();
|
||||
let prevented = 0;
|
||||
const event = {
|
||||
target: t.els['geo-import-button'],
|
||||
dataTransfer: { types: ['Files'], files: [file('panel.kml')] },
|
||||
preventDefault: () => prevented++,
|
||||
};
|
||||
t.document.emit('dragover', event);
|
||||
t.document.emit('drop', event);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(prevented, 2);
|
||||
assert.equal(t.tool.list()[0].name, 'panel.kml');
|
||||
t.tool.destroy();
|
||||
assert.equal(t.document.listeners.length, 0);
|
||||
});
|
||||
|
||||
test('ground refresh coalesces map changes and releases its subscription on teardown', async () => {
|
||||
let changed,
|
||||
unsubscribed = 0,
|
||||
refreshed = 0;
|
||||
const t = setup({
|
||||
subscribeMapSource: (listener) => {
|
||||
changed = listener;
|
||||
return () => unsubscribed++;
|
||||
},
|
||||
load: async () => ({
|
||||
name: 'point.geojson',
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
dataSource: {},
|
||||
refreshGround: async () => refreshed++,
|
||||
}),
|
||||
});
|
||||
await t.tool.importFiles([file('point.geojson')]);
|
||||
changed();
|
||||
changed();
|
||||
changed();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
assert.equal(refreshed, 1);
|
||||
t.tool.destroy();
|
||||
changed();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
assert.equal(refreshed, 1);
|
||||
assert.equal(unsubscribed, 1);
|
||||
});
|
||||
|
||||
test('Fly to is an immediate navigation that Cockpit can refuse', async () => {
|
||||
const flown = [];
|
||||
const nav = navigationDouble({ immediate: false });
|
||||
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
|
||||
// Batch auto-fly is deferred: stamped at the start, re-asserted before flying.
|
||||
await t.tool.importFiles([file('a.geojson')]);
|
||||
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
|
||||
assert.equal(flown.length, 1);
|
||||
// The button goes through the immediate route; refused, nothing flies.
|
||||
const id = t.tool.list()[0].id;
|
||||
assert.equal(t.tool.flyTo(id), false);
|
||||
assert.deepEqual(nav.calls.at(-1), ['immediate', 'file']);
|
||||
assert.equal(flown.length, 1);
|
||||
const nav2 = navigationDouble();
|
||||
const t2 = setup({
|
||||
navigation: nav2,
|
||||
flyToSource: (v, ds) => flown.push(ds),
|
||||
});
|
||||
await t2.tool.importFiles([file('b.geojson')]);
|
||||
assert.equal(t2.tool.flyTo(t2.tool.list()[0].id), true);
|
||||
assert.equal(flown.length, 3);
|
||||
});
|
||||
|
||||
test('a slow import does not fly once something newer took the camera', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => (release = r));
|
||||
const flown = [];
|
||||
const nav = navigationDouble({ reassert: false });
|
||||
const t = setup({
|
||||
navigation: nav,
|
||||
flyToSource: (v, ds) => flown.push(ds),
|
||||
load: async (f) => {
|
||||
await gate;
|
||||
return {
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const pending = t.tool.importFiles([file('slow.geojson')]);
|
||||
release();
|
||||
const [out] = await pending;
|
||||
assert.equal(out.ok, true);
|
||||
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
|
||||
assert.equal(flown.length, 0);
|
||||
});
|
||||
|
||||
test('a queued import cannot fly over navigation that happened while it waited', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let generation = 0;
|
||||
const flown = [];
|
||||
const nav = {
|
||||
beginDeferredLocationNavigation: () => ++generation,
|
||||
reassertDeferredLocationNavigation: (stamp) => stamp === generation,
|
||||
};
|
||||
const t = setup({
|
||||
navigation: nav,
|
||||
flyToSource: (v, ds) => flown.push(ds),
|
||||
load: async (f) => {
|
||||
if (f.name === 'first.geojson') await gate;
|
||||
return {
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
revoke() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const first = t.tool.importFiles([file('first.geojson')]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const queued = t.tool.importFiles([file('queued.geojson')]);
|
||||
generation++; // A newer user camera action while the second batch is waiting.
|
||||
release();
|
||||
const results = await Promise.all([first, queued]);
|
||||
assert.ok(results.every(([outcome]) => outcome.ok));
|
||||
assert.equal(t.tool.list().length, 2);
|
||||
assert.equal(flown.length, 0);
|
||||
});
|
||||
|
||||
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
|
||||
const flown = [];
|
||||
const nav = navigationDouble();
|
||||
nav.beginDeferredLocationNavigation = () => false;
|
||||
nav.reassertDeferredLocationNavigation = () => {
|
||||
throw new Error('must not re-assert a refused stamp');
|
||||
};
|
||||
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
|
||||
const [out] = await t.tool.importFiles([file('a.geojson')]);
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal(flown.length, 0);
|
||||
});
|
||||
|
||||
test('keyboard focus stays on the control after hide, show and remove', async () => {
|
||||
const t = setup();
|
||||
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
|
||||
// Clicks reach the list's delegated listener, as they bubble in a page.
|
||||
const press = (btn) =>
|
||||
t.els['geo-import-list'].emit('click', { target: btn });
|
||||
const [first] = rows(t.els);
|
||||
action(first, 'toggle').focus();
|
||||
press(action(first, 'toggle'));
|
||||
// The list was rebuilt; focus is on the NEW toggle of the same row.
|
||||
const again = rows(t.els)[0];
|
||||
assert.notEqual(again, first);
|
||||
assert.equal(t.document.activeElement, action(again, 'toggle'));
|
||||
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
|
||||
press(action(again, 'toggle'));
|
||||
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
|
||||
// Remove the first row: focus moves to the same control on the next row.
|
||||
action(rows(t.els)[0], 'remove').focus();
|
||||
press(action(rows(t.els)[0], 'remove'));
|
||||
assert.equal(rows(t.els).length, 1);
|
||||
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
|
||||
// Remove the last row: focus goes back to the Import button.
|
||||
press(action(rows(t.els)[0], 'remove'));
|
||||
assert.equal(t.document.activeElement, t.els['geo-import-button']);
|
||||
});
|
||||
|
||||
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
|
||||
const t = setup({
|
||||
load: async (f) => ({
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: f.name.endsWith('.kml') ? 'kml' : 'gpx',
|
||||
entityCount: 2,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
}),
|
||||
});
|
||||
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
|
||||
const [kml, gpx] = rows(t.els);
|
||||
assert.ok(kml.children[0].classList.contains('own-styles'));
|
||||
assert.equal(kml.children[0].style.background, undefined);
|
||||
assert.match(kml.children[1].title, /drawn with its own styles/);
|
||||
assert.ok(!gpx.children[0].classList.contains('own-styles'));
|
||||
assert.ok(gpx.children[0].style.background);
|
||||
});
|
||||
@@ -96,7 +96,13 @@ const PANEL_GROUPS = [
|
||||
},
|
||||
{
|
||||
label: msg('Events'),
|
||||
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
|
||||
ids: [
|
||||
'rocket-launches',
|
||||
'earthquakes',
|
||||
'local-firms',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: msg('Weather'),
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
#cockpit-hud {
|
||||
--cockpit-accent: #00dcff;
|
||||
/* Matches EMERGENCY_TINT_CSS in src/data/aircraftEmergency.js. */
|
||||
--cockpit-emergency: #ff4444;
|
||||
--cockpit-side-panel-width: min(340px, 28vw);
|
||||
/* HUD bottom offset + tallest scaled telemetry block + 20px clear border. */
|
||||
--cockpit-bottom-rail-safe: calc(clamp(84px, 11vh, 118px) + 100px);
|
||||
@@ -496,6 +498,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
|
||||
font-size: 7px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
.cockpit-aircraft-meta.emergency {
|
||||
color: var(--cockpit-emergency);
|
||||
font-weight: 600;
|
||||
}
|
||||
.cockpit-mode-chip {
|
||||
margin-top: 2px;
|
||||
padding: 7px 11px;
|
||||
|
||||
@@ -1039,6 +1039,80 @@ body.gev-drawing #cesiumContainer canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* DISPLAY ▸ Import: one row per imported file, and the drop-target outline */
|
||||
.geo-import-list {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 6px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.geo-import-list.visible {
|
||||
display: flex;
|
||||
}
|
||||
.geo-import-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.geo-import-swatch {
|
||||
flex: 0 0 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* KML/KMZ draw with the styles written in the file, so the swatch shows a
|
||||
hatch instead of a color the globe does not use. */
|
||||
.geo-import-swatch.own-styles {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--text-dim) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
box-shadow: inset 0 0 0 1px var(--text-dim);
|
||||
}
|
||||
.geo-import-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.geo-import-action {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.geo-import-action:hover,
|
||||
.geo-import-action:focus-visible {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.geo-import-action .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
.geo-import-hint:empty {
|
||||
display: none;
|
||||
}
|
||||
body.gev-geo-import-drag #cesiumContainer {
|
||||
outline: 2px dashed var(--accent);
|
||||
outline-offset: -6px;
|
||||
}
|
||||
|
||||
/* Proximity / All segmented control under the 3D toggle */
|
||||
#models3d-mode-row {
|
||||
min-width: 184px;
|
||||
|
||||
@@ -2270,6 +2270,12 @@
|
||||
color: rgba(205, 225, 226, 0.48);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
body.cockpit-mode
|
||||
.cockpit-aircraft-meta.emergency {
|
||||
color: var(--cyber-red-bright);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] body.cockpit-mode .cockpit-vision-controls {
|
||||
margin-top: 0;
|
||||
border-color: rgba(105, 203, 209, 0.38);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSurfaceKeyboard } from './surfaceKeyboard.js';
|
||||
test('surface cycling never targets deliberately excluded controls', () => {
|
||||
let handler;
|
||||
const documentRef = {
|
||||
activeElement: {},
|
||||
addEventListener: (_, fn) => (handler = fn),
|
||||
removeEventListener() {},
|
||||
};
|
||||
const node = ({
|
||||
attributes = {},
|
||||
fieldsetDisabled = false,
|
||||
inert = false,
|
||||
} = {}) => ({
|
||||
hasAttribute: (name) => Object.hasOwn(attributes, name),
|
||||
getClientRects: () => [{}],
|
||||
getAttribute: (name) => attributes[name] ?? null,
|
||||
matches: (selector) => selector === ':disabled' && fieldsetDisabled,
|
||||
closest: (selector) =>
|
||||
selector === '[inert]' && inert ? { inert: true } : null,
|
||||
focus() {
|
||||
documentRef.activeElement = this;
|
||||
},
|
||||
});
|
||||
const ignored = [
|
||||
node({ attributes: { tabindex: '-1' } }),
|
||||
node({ fieldsetDisabled: true }),
|
||||
node({ inert: true }),
|
||||
];
|
||||
const first = node({}),
|
||||
last = node({});
|
||||
const controls = [...ignored, first, last];
|
||||
const root = {
|
||||
ownerDocument: documentRef,
|
||||
querySelectorAll: () => controls,
|
||||
contains: (n) => controls.includes(n),
|
||||
};
|
||||
const surface = createSurfaceKeyboard({
|
||||
root,
|
||||
isActive: () => true,
|
||||
onEscape() {},
|
||||
});
|
||||
surface.activate();
|
||||
const press = (shiftKey) =>
|
||||
handler({
|
||||
key: 'Tab',
|
||||
shiftKey,
|
||||
preventDefault() {},
|
||||
stopPropagation() {},
|
||||
});
|
||||
press(false);
|
||||
assert.equal(documentRef.activeElement, first);
|
||||
press(true);
|
||||
assert.equal(documentRef.activeElement, last);
|
||||
surface.destroy();
|
||||
});
|
||||
@@ -39,7 +39,11 @@ export function createSurfaceKeyboard({
|
||||
),
|
||||
].filter(
|
||||
(node) =>
|
||||
!node.hasAttribute('disabled') && node.getClientRects().length > 0,
|
||||
!node.hasAttribute('disabled') &&
|
||||
!node.matches?.(':disabled') &&
|
||||
!(Number(node.getAttribute?.('tabindex')) < 0) &&
|
||||
!node.closest?.('[inert]') &&
|
||||
node.getClientRects().length > 0,
|
||||
);
|
||||
if (!order.length) return;
|
||||
const first = order[0];
|
||||
|
||||
@@ -147,6 +147,15 @@
|
||||
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group" id="geo-import-group">
|
||||
<button class="pp-toggle-btn" id="geo-import-button" type="button" title="Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice." data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">upload_file</span>
|
||||
<span class="pp-label" data-i18n>Import</span>
|
||||
</button>
|
||||
<input type="file" id="geo-import-input" accept=".geojson,.json,.kml,.kmz,.gpx" multiple hidden aria-label="Choose GeoJSON, KML, KMZ or GPX files" data-i18n-aria-label>
|
||||
<ul class="geo-import-list" id="geo-import-list" aria-label="Imported files" data-i18n-aria-label></ul>
|
||||
<span class="draw-hint geo-import-hint" id="geo-import-hint" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||
|
||||
@@ -36,8 +36,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// consolidated tool wording (each policy stated once); model-facing tools
|
||||
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
|
||||
// requested radius and center authority in descriptions only.
|
||||
// Street Level adds its toggle enum values and generated alias hint.
|
||||
'9eb113454c5af5e32e27ffcdc7919d0e4600ba1d204f8f175e071152be41ad0f',
|
||||
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
|
||||
'9398815a573ab29ecc3ce527f6e1a2ad883804e7696e0444aebb11f973700d4b',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4818,9 +4818,13 @@ function compactAnalystItem(r) {
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
if (!present(r[key]) || key in compact) continue;
|
||||
if (spec.type === 'time') {
|
||||
const ms = Number(r[key]);
|
||||
// A finite value can still be past Date's range, where toISOString throws.
|
||||
const time = new Date(Number(r[key]));
|
||||
if (Number.isFinite(time.getTime())) compact.time = time.toISOString();
|
||||
const time = new Date(ms);
|
||||
if (Number.isFinite(time.getTime())) {
|
||||
if (key === 'lastContactAtMs') compact.lastContactAtMs = ms;
|
||||
else compact.time = time.toISOString();
|
||||
}
|
||||
} else compact[key] = r[key];
|
||||
}
|
||||
if (present(r.ageHours)) compact.ageHours = r.ageHours;
|
||||
|
||||
@@ -3180,7 +3180,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl
|
||||
const requests = [];
|
||||
installKeyedFetch(t, async (url) => {
|
||||
requests.push(String(url));
|
||||
if (String(url).startsWith('https://maps.googleapis.com/')) {
|
||||
if (String(url).startsWith('/api/google/geocode')) {
|
||||
return { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) };
|
||||
}
|
||||
assert.match(String(url), /^https:\/\/photon\.komoot\.io\/api\/\?/);
|
||||
@@ -3205,7 +3205,7 @@ test('voice Radio: a key that geocodes to nothing still places the station, keyl
|
||||
assert.ok(Math.abs(calls[0].criteria.anchor.lat - 20.9101) < 1e-9);
|
||||
assert.ok(Math.abs(calls[0].criteria.anchor.lon - 107.1839) < 1e-9);
|
||||
// Google is asked first and exactly once; Photon answers unbiased, in one call.
|
||||
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);
|
||||
assert.match(requests.at(-1), /[?&]q=H%E1%BA%A1\+Long\+Bay/);
|
||||
assert.doesNotMatch(requests.at(-1), /[?&](lat|lon|bbox)=/, 'a named radio location is not viewport-biased');
|
||||
@@ -3218,7 +3218,7 @@ test('voice Radio: the keyless path applies no country filter the keyed path wou
|
||||
// "No Radio station matched" for exactly the places it just resolved, while a
|
||||
// keyed install placed a station. The label may carry it; the filter may not.
|
||||
const { calls, dataManager } = radioSelectionHarness();
|
||||
installKeyedFetch(t, async (url) => (String(url).startsWith('https://maps.googleapis.com/')
|
||||
installKeyedFetch(t, async (url) => (String(url).startsWith('/api/google/geocode')
|
||||
? { ok: true, json: async () => ({ status: 'ZERO_RESULTS', results: [] }) }
|
||||
: {
|
||||
ok: true,
|
||||
@@ -3263,6 +3263,30 @@ test('ALPR common names toggle only the registered camera layer through the norm
|
||||
}
|
||||
});
|
||||
|
||||
test('GNSS integrity and interference common names toggle only the GNSS layer through the normal voice action', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: { moveEnd: { addEventListener() {} } } };
|
||||
const calls = [];
|
||||
let enabled = false;
|
||||
const dataManager = {
|
||||
layers: new Map([['gnss-integrity', { module: {} }]]),
|
||||
getAll: () => [{ id: 'gnss-integrity', name: 'GNSS Integrity' }],
|
||||
isEnabled: () => enabled,
|
||||
setEnabled: async (id, value) => { calls.push([id, value]); enabled = value; return true; },
|
||||
};
|
||||
const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager });
|
||||
for (const alias of ['gnss-integrity', 'gnss integrity', 'navigation integrity', 'low navigation accuracy', 'gnss interference', 'gps interference', 'gps jamming', 'gnss']) {
|
||||
for (const value of [true, false]) {
|
||||
const result = await runner('set_layer_visibility', { layerId: alias, enabled: value });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.layerId, 'gnss-integrity');
|
||||
assert.deepEqual(calls.at(-1), ['gnss-integrity', value]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Local ADS-B common names toggle only the receiver layer through the normal voice action', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const viewer = { clock: { onTick: { addEventListener: () => () => {} } },
|
||||
|
||||
@@ -34,9 +34,29 @@ const AIRCRAFT_FIELDS = {
|
||||
aircraftClass: ['t'],
|
||||
military: ['f'],
|
||||
onGround: ['f'],
|
||||
squawk: ['t'],
|
||||
emergency: ['f'],
|
||||
emergencyKind: ['t'],
|
||||
emergencySource: ['t'],
|
||||
emergencyBroadcast: ['t'],
|
||||
lastContactAtMs: ['ms'],
|
||||
};
|
||||
|
||||
const layers = [
|
||||
{
|
||||
id: 'gnss-integrity',
|
||||
aliases: [
|
||||
'gnss integrity',
|
||||
'navigation integrity',
|
||||
'low navigation accuracy',
|
||||
'gnss interference',
|
||||
'gps interference',
|
||||
'gps jamming',
|
||||
'gnss',
|
||||
],
|
||||
context: false,
|
||||
noQuery: 'navigation-integrity cells; no countable analyst records',
|
||||
},
|
||||
{
|
||||
id: 'flights',
|
||||
track: true,
|
||||
|
||||
Reference in new issue
Block a user