mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge main into pr-705
Resolve the CHANGELOG conflict with Fire Perimeters (#737) by keeping both entries. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
351b5fcaa4
184 files changed
+23241
-252
No files matched your search
@@ -0,0 +1,86 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FEATURE_SOURCE_METHODS } from '../sources/featureSource.js';
|
||||
import { createAnnotationResolver } from './resolver.js';
|
||||
|
||||
function featureSource(overrides = {}) {
|
||||
const source = {};
|
||||
for (const method of FEATURE_SOURCE_METHODS)
|
||||
source[method] = async () => null;
|
||||
return Object.assign(source, overrides);
|
||||
}
|
||||
|
||||
const texasGeocode = {
|
||||
async geocode() {
|
||||
return {
|
||||
place: {
|
||||
lat: 31.0,
|
||||
lng: -99.9,
|
||||
label: 'Texas, USA',
|
||||
name: 'Texas',
|
||||
types: ['administrative_area_level_1', 'political'],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('region ring: Natural Earth names resolve without a geocoder', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource(),
|
||||
});
|
||||
for (const name of ['Gulf of Mexico', 'the Alps']) {
|
||||
const region = await resolveRegionRingForQuery(name);
|
||||
assert.ok(region?.ring?.length >= 3, `${name} resolves to a ring`);
|
||||
assert.equal(region.error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('region ring: a slow admin-boundary lookup returns region-timeout within the budget', async () => {
|
||||
let adminSignal;
|
||||
let release;
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas(_point, { signal }) {
|
||||
adminSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
const started = Date.now();
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
{ budgetMs: 50 },
|
||||
);
|
||||
assert.deepEqual(region, {
|
||||
name: 'Texas',
|
||||
ring: null,
|
||||
error: 'region-timeout',
|
||||
});
|
||||
assert.ok(
|
||||
Date.now() - started < 1000,
|
||||
'returns at the budget, not the lookup',
|
||||
);
|
||||
// The lookup is left running so it can fill the boundary cache.
|
||||
assert.equal(adminSignal?.aborted ?? false, false);
|
||||
release(null);
|
||||
});
|
||||
|
||||
test('region ring: an unbounded budget waits for the lookup', async () => {
|
||||
const { resolveRegionRingForQuery } = createAnnotationResolver({
|
||||
featureSource: featureSource({
|
||||
getAdministrativeAreas: () =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 30)),
|
||||
}),
|
||||
});
|
||||
const region = await resolveRegionRingForQuery(
|
||||
'Texas',
|
||||
undefined,
|
||||
texasGeocode,
|
||||
{ budgetMs: Infinity },
|
||||
);
|
||||
assert.equal(region, null);
|
||||
});
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
import { unavailablePlaceSearch } from '../search/placeSearch.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
|
||||
/** Ms the analyst region lookup waits on geocode + admin boundary. */
|
||||
export const REGION_FALLBACK_BUDGET_MS = 3_000;
|
||||
|
||||
/** Own annotation lookup caches and ranking of supplied feature candidates. */
|
||||
export function createAnnotationResolver({
|
||||
boundarySource,
|
||||
@@ -1877,14 +1880,22 @@ export function createAnnotationResolver({
|
||||
* resolve to a region-like boundary — the analyst engine reports that
|
||||
* honestly rather than silently scoping to nothing.
|
||||
*
|
||||
* The geocode + admin-boundary rung is capped at `budgetMs`. Past it the
|
||||
* call returns `{name, ring: null, error: 'region-timeout'}` so a voice
|
||||
* answer is not held for tens of seconds; the lookup keeps running and
|
||||
* fills the boundary cache, so asking again shortly is fast.
|
||||
*
|
||||
* @param {string} name e.g. "Texas", "the Alps", "France", "Gulf of Mexico"
|
||||
* @param {AbortSignal} [signal]
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|null>}
|
||||
* @param {object} [placeSearch]
|
||||
* @param {{budgetMs?: number}} [options] `Infinity` waits for the lookup.
|
||||
* @returns {Promise<{name:string, ring:Array<[number,number]>}|{name:string, ring:null, error:'region-timeout'}|null>}
|
||||
*/
|
||||
async function resolveRegionRingForQuery(
|
||||
name,
|
||||
signal,
|
||||
placeSearch = unavailablePlaceSearch,
|
||||
{ budgetMs = REGION_FALLBACK_BUDGET_MS } = {},
|
||||
) {
|
||||
signal = lifetime
|
||||
? signal
|
||||
@@ -1901,6 +1912,23 @@ export function createAnnotationResolver({
|
||||
const ring = [...ne.polygons].sort((a, b) => b.length - a.length)[0];
|
||||
if (ring?.length >= 3) return { name: ne.name, ring };
|
||||
}
|
||||
const lookup = resolveAdminRegionRing(q, signal, placeSearch);
|
||||
if (!Number.isFinite(budgetMs)) return lookup;
|
||||
let timer;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ name: q, ring: null, error: 'region-timeout' }),
|
||||
budgetMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([lookup, timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAdminRegionRing(q, signal, placeSearch) {
|
||||
const geo = await geocodePlace(q, null, signal, placeSearch).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
||||
militaryAwarenessLayer: 'military-awareness',
|
||||
militaryInstallationsLayer: 'military-installations',
|
||||
rocketLaunchesLayer: 'rocket-launches',
|
||||
localAdsbLayer: 'local-adsb',
|
||||
});
|
||||
|
||||
/** Capture the ordered application instances and their serialization metadata. */
|
||||
|
||||
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
'military-awareness',
|
||||
'military-installations',
|
||||
'rocket-launches',
|
||||
'local-adsb',
|
||||
];
|
||||
const layers = ids.map((id) => ({ id }));
|
||||
const catalog = createLayerCatalog(layers, metadata(layers));
|
||||
@@ -100,6 +101,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
||||
assert.equal(services.flightsLayer, layers[1]);
|
||||
assert.equal(services.transitLayer, layers[7]);
|
||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||
assert.equal(services.localAdsbLayer, layers[12]);
|
||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||
assert.throws(
|
||||
() => catalogControlServices(createLayerCatalog([], [])),
|
||||
|
||||
@@ -19,9 +19,11 @@ import { createApplicationInstallations } from './layers/militaryInstallations.j
|
||||
import { createApplicationSatellites } from './layers/satellites.js';
|
||||
import { createApplicationLaunches } from './layers/rocketLaunches.js';
|
||||
import { createApplicationAlpr } from './layers/alprCameras.js';
|
||||
import { createApplicationLocalAdsb } from './layers/localAdsb.js';
|
||||
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 { createApplicationCables } from './layers/submarineCables.js';
|
||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||
@@ -51,9 +53,24 @@ const SOURCE_METHODS = Object.freeze({
|
||||
weather: ['getSnapshot'],
|
||||
cyclones: ['getSnapshot'],
|
||||
earthquakes: ['getSnapshot'],
|
||||
'fire-perimeters': ['getSnapshot'],
|
||||
cables: ['fetch'],
|
||||
});
|
||||
|
||||
/**
|
||||
* Hardware-local layers are registered like any other but never enter share
|
||||
* links or stored layer state: another browser cannot have this receiver.
|
||||
*/
|
||||
export const LOCAL_ONLY_LAYER_METADATA = Object.freeze([
|
||||
Object.freeze({ id: 'local-adsb', disposition: 'local-only' }),
|
||||
]);
|
||||
|
||||
/** Serialization metadata for every layer the application catalog constructs. */
|
||||
export const APPLICATION_LAYER_METADATA = Object.freeze([
|
||||
...LAYER_STATE_REGISTRY,
|
||||
...LOCAL_ONLY_LAYER_METADATA,
|
||||
]);
|
||||
|
||||
/** Construct the current catalog without choosing any source provider.
|
||||
* Scene engines remain page-owned; layers and classification have this app's lifetime.
|
||||
* The manager owns layer destruction, while abort releases classification even if startup fails.
|
||||
@@ -62,7 +79,7 @@ export function createApplicationCatalog({
|
||||
surface,
|
||||
sources,
|
||||
signal,
|
||||
metadata = LAYER_STATE_REGISTRY,
|
||||
metadata = APPLICATION_LAYER_METADATA,
|
||||
vesselOptions,
|
||||
resolveAsset,
|
||||
nepalBoundaryResolver,
|
||||
@@ -120,7 +137,16 @@ export function createApplicationCatalog({
|
||||
}),
|
||||
flights,
|
||||
military,
|
||||
createApplicationLocalAdsb({
|
||||
surface,
|
||||
enrichment: sources.flights,
|
||||
displayParams: () => flights.getParams(),
|
||||
...(resolveAsset ? { resolveAsset } : {}),
|
||||
}),
|
||||
createApplicationEarthquakes({ source: sources.earthquakes }),
|
||||
createApplicationFirePerimeters({
|
||||
source: sources['fire-perimeters'],
|
||||
}),
|
||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||
satellites,
|
||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||
|
||||
@@ -39,7 +39,13 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
signal: b.signal,
|
||||
surface: fixtureSurface(b.signal),
|
||||
});
|
||||
assert.equal(first.layers.length, 27);
|
||||
assert.equal(first.layers.length, 29);
|
||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||
assert.deepEqual(
|
||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||
{ id: 'local-adsb', disposition: 'local-only' },
|
||||
'the hardware-local layer is never serialized into links',
|
||||
);
|
||||
assert.notEqual(first.weatherClock, second.weatherClock);
|
||||
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
|
||||
assert.match(
|
||||
@@ -52,6 +58,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
||||
first.get(id).getDiagnostics().clock.target,
|
||||
'2026-09-21T12:00:00.000Z',
|
||||
);
|
||||
assert.ok(first.get('fire-perimeters'));
|
||||
assert.ok(first.get('transit'));
|
||||
const order = first.layers.map(({ id }) => id);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LayerLifecycle } from '../data/lifecycle.js';
|
||||
import { LayerPresentation } from './layerPresentation.js';
|
||||
import { createCyberSonarScene } from '../cyberSonarScene.js';
|
||||
/** Register the application layer catalog before allowing state restoration. */
|
||||
export function createApplicationData({
|
||||
scene: { viewer, mapStackController },
|
||||
@@ -55,6 +56,7 @@ export function createApplicationData({
|
||||
}
|
||||
presentation.mount(document.getElementById('data-toggles'));
|
||||
styleManager.attachDataManager(dataManager);
|
||||
defer(createCyberSonarScene(viewer, dataManager));
|
||||
|
||||
return { dataManager, catalog, presentation };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createLocalAdsbLayer } from '../../layers/localAdsb/index.js';
|
||||
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
|
||||
import { SdrController } from '../../sdr/controller.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as trails from '../../data/trailRenderer.js';
|
||||
import * as geoid from '../../data/geoid.js';
|
||||
import { markDetectionSourcesChanged } from '../../data/detection.js';
|
||||
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
||||
|
||||
/**
|
||||
* Construct the Local ADS-B layer, the browser RTL-SDR session it shares with
|
||||
* the Radio panel, and the decoder-feed session. Construction opens no device
|
||||
* and makes no request; the SDR starts only from an explicit Connect and the
|
||||
* feeds are polled only while the layer is enabled.
|
||||
*
|
||||
* `displayParams` reads the public Flights layer's DISPLAY-rail 3D preference
|
||||
* (`{ models3d, models3dMode }`) so local aircraft follow the same toggle;
|
||||
* `enrichment` is the Flights source whose cached adsbdb proxy they share.
|
||||
*/
|
||||
export function createApplicationLocalAdsb({
|
||||
receiver = new SdrController(),
|
||||
feeds = createLocalReceiverFeeds(),
|
||||
surface = null,
|
||||
enrichment = null,
|
||||
displayParams = null,
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
} = {}) {
|
||||
return createLocalAdsbLayer({
|
||||
receiver,
|
||||
feeds,
|
||||
resolveAsset,
|
||||
services: {
|
||||
render,
|
||||
context,
|
||||
picking,
|
||||
trails,
|
||||
geoid,
|
||||
groundSnap: surface?.groundSnap || null,
|
||||
enrichment,
|
||||
display: displayParams ? { getParams: displayParams } : null,
|
||||
detection: { markSourcesChanged: markDetectionSourcesChanged },
|
||||
overlays: { refreshReadout: refreshTrackedReadout },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createFirePerimetersLayer,
|
||||
createInciwebIndexSource,
|
||||
createInciwebPublicationSource,
|
||||
} from '../../layers/perimeters/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/** Wire WFIGS fire perimeters into the application catalog. */
|
||||
export function createApplicationFirePerimeters(options) {
|
||||
return createFirePerimetersLayer({
|
||||
overlayHost: {
|
||||
setEntries: overlays.setOverlayEntries,
|
||||
setVisible: overlays.setOverlaySourceVisible,
|
||||
clearSource: overlays.clearOverlaySource,
|
||||
hitTest: overlays.hitTestWorldOverlay,
|
||||
},
|
||||
inciwebSource: createInciwebIndexSource(),
|
||||
inciwebPublications: createInciwebPublicationSource(),
|
||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||
screenSpaceEventHandlerFactory: (viewer) =>
|
||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
||||
picking,
|
||||
pointer: { isPointerFree },
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -22,7 +22,7 @@ test('no browser-built module imports a Node core module', () => {
|
||||
// import survives the build and turns into a runtime failure the moment the
|
||||
// guard around it is wrong. src/data/naturalEarthRegions.js and
|
||||
// src/data/neighborhoodPolygons.js both carried one to read their bundled
|
||||
// JSON packs under node:test; an import attribute serves both runtimes.
|
||||
// JSON packs under node:test; src/data/bundledJson.js now serves both runtimes.
|
||||
const offenders = [];
|
||||
for (const file of browserModules()) {
|
||||
const source = readFileSync(file, 'utf8');
|
||||
|
||||
@@ -342,11 +342,11 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
|
||||
|
||||
test('Cockpit Display portal retains both scroll owners across round trips', () => {
|
||||
const portal = CockpitDisplayPortal.toString();
|
||||
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
|
||||
assert.match(portal, /this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\?\.scrollTop \|\| 0/);
|
||||
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
|
||||
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
|
||||
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\.scrollTop/);
|
||||
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
|
||||
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
|
||||
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?displayPanelScroller\(this\.standardPanel\)\.scrollTop =\s*this\.standardScrollTop/);
|
||||
});
|
||||
|
||||
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
* @param {number} [input.signalGap] Clearance above the briefing card.
|
||||
* @param {number} [input.minTopFloor] Absolute ceiling for the strip, in px.
|
||||
* @param {number} [input.minTopRatio] Viewport-relative ceiling, 0..1.
|
||||
* @param {boolean} [input.signalCollapsed] Whether the briefing card is collapsed.
|
||||
* @param {boolean} [input.utilityExpanded] Whether Display or Radio is expanded.
|
||||
* @param {number} [input.bottomInset] Reserved viewport space below an expanded panel.
|
||||
* @param {number} [input.signalHeight] Collapsed briefing header reserved below the utility.
|
||||
* @param {boolean} [input.reserveViewportLane] Theme relocates the briefing below the expanded utility.
|
||||
* @returns {{ top: number, maxHeight: number }}
|
||||
*/
|
||||
export function resolveCockpitUtilityAnchor({
|
||||
@@ -29,13 +34,29 @@ export function resolveCockpitUtilityAnchor({
|
||||
signalGap = 8,
|
||||
minTopFloor = 96,
|
||||
minTopRatio = 0.12,
|
||||
signalCollapsed = false,
|
||||
utilityExpanded = false,
|
||||
bottomInset = 80,
|
||||
signalHeight = 0,
|
||||
reserveViewportLane = false,
|
||||
}) {
|
||||
const viewport = Math.max(0, Number(viewportHeight) || 0);
|
||||
const minTop = Math.max(minTopFloor, viewport * minTopRatio);
|
||||
const anchoredTop = Math.max(minTop, (Number(recBottom) || 0) + recGap);
|
||||
const strip = Math.max(0, Number(stripHeight) || 0);
|
||||
const signal = Number(signalTop);
|
||||
const lowerBound = Number.isFinite(signal) ? signal : viewport;
|
||||
const expandedLaneBottom = Math.max(
|
||||
0,
|
||||
viewport -
|
||||
Math.max(0, Number(bottomInset) || 0) -
|
||||
Math.max(0, Number(signalHeight) || 0),
|
||||
);
|
||||
const lowerBound =
|
||||
reserveViewportLane && signalCollapsed && utilityExpanded
|
||||
? expandedLaneBottom
|
||||
: Number.isFinite(signal)
|
||||
? signal
|
||||
: viewport;
|
||||
const clearedTop = lowerBound - signalGap - strip;
|
||||
const top = Math.max(minTop, Math.min(anchoredTop, clearedTop));
|
||||
const floor = Math.max(0, Number(collapsedHeight) || 0) || 50;
|
||||
@@ -79,3 +100,25 @@ export function resolveCockpitUtilityLayout({
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shared Cyber panel slot, with footer controls above the lower frame (83vh). */
|
||||
export function resolveCyberCockpitPanelLane({
|
||||
viewportHeight,
|
||||
top,
|
||||
launcherHeight = 60,
|
||||
signalHeight = 44,
|
||||
contactHeight = 64,
|
||||
}) {
|
||||
const gap = 8;
|
||||
const footerHeight = Math.max(
|
||||
contactHeight + gap,
|
||||
launcherHeight + signalHeight + gap * 2,
|
||||
);
|
||||
const bottom = viewportHeight * 0.82;
|
||||
const panelHeight = Math.max(120, bottom - top - footerHeight);
|
||||
return {
|
||||
panelHeight,
|
||||
utilityHeight: panelHeight + gap + launcherHeight,
|
||||
signalTop: top + panelHeight + launcherHeight + gap * 2,
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,58 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveCockpitUtilityAnchor,
|
||||
resolveCockpitUtilityLayout,
|
||||
resolveCyberCockpitPanelLane,
|
||||
} from './cockpitUtilityLayout.js';
|
||||
|
||||
// 1512x790, the height the strip used to collide with the briefing card at.
|
||||
const desktop = { viewportHeight: 790, stripHeight: 107, collapsedHeight: 50 };
|
||||
|
||||
test('Cyber shared panel slot reserves both footer lanes above the decorative frame', () => {
|
||||
for (const viewportHeight of [600, 673, 713, 986, 1376]) {
|
||||
for (const signalHeight of [44, 76]) {
|
||||
for (const contactHeight of [44, 84, 120]) {
|
||||
const top = Math.max(164, Math.min(204, viewportHeight * 0.19));
|
||||
const lane = resolveCyberCockpitPanelLane({ viewportHeight, top, signalHeight, contactHeight });
|
||||
assert.ok(lane.panelHeight >= 120);
|
||||
assert.ok(top + lane.panelHeight + 8 + contactHeight <= viewportHeight * 0.82 + .01);
|
||||
assert.ok(lane.signalTop + signalHeight <= viewportHeight * 0.82 + .01);
|
||||
assert.equal(lane.signalTop, top + lane.utilityHeight + 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Cyber keeps the Data Layers top while reserving briefing clearance', () => {
|
||||
for (const viewportHeight of [673, 713, 986, 1376]) {
|
||||
const leftTop = Math.max(164, Math.min(204, viewportHeight * 0.19));
|
||||
const collapsed = resolveCockpitUtilityAnchor({
|
||||
viewportHeight, stripHeight: 128, collapsedHeight: 60, recBottom: 0,
|
||||
signalTop: leftTop + 128 + 28, minTopFloor: leftTop, minTopRatio: 0,
|
||||
});
|
||||
assert.equal(collapsed.top, leftTop);
|
||||
assert.ok(collapsed.maxHeight >= 128);
|
||||
const expanded = resolveCockpitUtilityAnchor({
|
||||
viewportHeight, stripHeight: 700, collapsedHeight: 60, recBottom: 0,
|
||||
signalTop: 255, minTopFloor: leftTop, minTopRatio: 0,
|
||||
signalCollapsed: true, utilityExpanded: true, reserveViewportLane: true, signalHeight: 44,
|
||||
});
|
||||
assert.equal(expanded.top, leftTop);
|
||||
assert.equal(expanded.top + expanded.maxHeight + 8 + 44 + 80, viewportHeight);
|
||||
}
|
||||
});
|
||||
|
||||
test('expanded utilities reserve the visible collapsed briefing header at every desktop height', () => {
|
||||
for (const viewportHeight of [640, 720, 987, 1376]) {
|
||||
for (const signalHeight of [48, 76, 100]) {
|
||||
const { top, maxHeight } = resolveCockpitUtilityAnchor({
|
||||
...desktop, viewportHeight, recBottom: 0, signalTop: 255,
|
||||
signalCollapsed: true, utilityExpanded: true, signalHeight, reserveViewportLane: true,
|
||||
});
|
||||
assert.equal(top + maxHeight + 8, viewportHeight - 80 - signalHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the strip hangs 12px under the REC readout when the briefing card leaves room', () => {
|
||||
const { top } = resolveCockpitUtilityAnchor({ ...desktop, recBottom: 148.1, signalTop: 420 });
|
||||
assert.equal(Number(top.toFixed(1)), 160.1);
|
||||
@@ -54,6 +101,37 @@ test('a missing REC readout leaves the strip on the viewport ceiling', () => {
|
||||
assert.equal(top, 96);
|
||||
});
|
||||
|
||||
test('an expanded utility claims the vertical lane released by a collapsed briefing', () => {
|
||||
const { top, maxHeight } = resolveCockpitUtilityAnchor({
|
||||
...desktop,
|
||||
recBottom: 148.1,
|
||||
signalTop: 265.4,
|
||||
signalCollapsed: true,
|
||||
utilityExpanded: true,
|
||||
reserveViewportLane: true,
|
||||
});
|
||||
assert.equal(Number(top.toFixed(1)), 160.1);
|
||||
assert.equal(Number(maxHeight.toFixed(1)), 541.9);
|
||||
});
|
||||
|
||||
test('non-relocating themes preserve the visible briefing boundary even when collapsed', () => {
|
||||
const { top, maxHeight } = resolveCockpitUtilityAnchor({
|
||||
...desktop, recBottom: 148.1, signalTop: 265.4,
|
||||
signalCollapsed: true, utilityExpanded: true,
|
||||
});
|
||||
assert.equal(Number((top + maxHeight).toFixed(1)), 257.4);
|
||||
});
|
||||
|
||||
test('a collapsed briefing still bounds the utility strip until a panel expands', () => {
|
||||
const { maxHeight } = resolveCockpitUtilityAnchor({
|
||||
...desktop,
|
||||
recBottom: 148.1,
|
||||
signalTop: 265.4,
|
||||
signalCollapsed: true,
|
||||
});
|
||||
assert.equal(Number(maxHeight.toFixed(1)), 107);
|
||||
});
|
||||
|
||||
test('keeps the collapsed sibling visible when both Cockpit utilities fit', () => {
|
||||
assert.deepEqual(resolveCockpitUtilityLayout({
|
||||
availableHeight: 320,
|
||||
@@ -94,4 +172,3 @@ test('clamps malformed or short corridors to the minimum expanded height', () =>
|
||||
expandedMaxHeight: 120,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* Scanner design inspired by kk376's tactical naval sonar visual style:
|
||||
* https://github.com/bilawalsidhu/gods-eye-view/pull/214
|
||||
*
|
||||
* This is a separate HUD/contact treatment, not a port of that PR's shader.
|
||||
*/
|
||||
export const CYBER_SONAR_PERIOD_MS = 5_200;
|
||||
// Parked-scene wake-ups and shared overlays use the fleet cadence. Native
|
||||
// contacts still compose after layer updates on each actual draw so owner
|
||||
// color writes cannot flash through during camera/fleet animation.
|
||||
export const CYBER_SONAR_RENDER_INTERVAL_MS = 80;
|
||||
export const CYBER_SONAR_SWEEP_DEG = 24;
|
||||
export const CYBER_SONAR_FEATHER_DEG = 10;
|
||||
// The sector should still read as a clear acquisition pass, but contacts
|
||||
// outside it must remain immediately legible against FLIR terrain. Icons and
|
||||
// dots keep a strong operator-tunable opacity floor; labels retain an even
|
||||
// brighter floor so typography never disappears between passes.
|
||||
export const CYBER_SONAR_DIM_FACTOR = 0.84;
|
||||
export const CYBER_SONAR_LABEL_DIM_FACTOR = 0.92;
|
||||
export const CYBER_SONAR_DEFAULTS = Object.freeze({
|
||||
rings: 8,
|
||||
range: 100,
|
||||
intensity: 70,
|
||||
opacity: 84,
|
||||
sector: 24,
|
||||
});
|
||||
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const primitiveColors = new WeakMap();
|
||||
const sweepAnimations = new WeakMap();
|
||||
|
||||
/** Pin the compositor animation to the absolute clock used by contact sampling.
|
||||
* A negative delay alone is relative to the animation's eventual start, which
|
||||
* may be much later than theme initialization (or restart after HUD hiding).
|
||||
* Cache the running animation: no per-frame style writes or animation queries.
|
||||
*/
|
||||
export function syncCyberSonarAnimation(
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
if (!root) return;
|
||||
const cached = sweepAnimations.get(root);
|
||||
if (cached?.element.isConnected && cached.animation.playState !== 'idle')
|
||||
return;
|
||||
const element = root.querySelector?.('.hud-sonar');
|
||||
const animation = element
|
||||
?.getAnimations?.({ subtree: true })
|
||||
.find((item) => item.animationName === 'cyber-sonar-sweep');
|
||||
if (!animation) return;
|
||||
// The document animation timeline and performance.now share the navigation
|
||||
// time origin. Zero both offsets so delayed starts cannot shift the sector.
|
||||
animation.effect.updateTiming({ delay: 0 });
|
||||
animation.startTime = 0;
|
||||
sweepAnimations.set(root, { element, animation });
|
||||
}
|
||||
|
||||
const copyColor = (color) => {
|
||||
if (!color || !Number.isFinite(color.alpha)) return null;
|
||||
if (typeof color.clone === 'function') return color.clone();
|
||||
return { ...color };
|
||||
};
|
||||
|
||||
/** Read owner opacity before a hue-only restyle, never our composited opacity.
|
||||
* An independently written color still wins, including real freshness changes.
|
||||
*/
|
||||
export function cyberSonarBaseAlpha(visual, property = 'color') {
|
||||
const current = visual?.[property];
|
||||
const state = primitiveColors.get(visual)?.[property];
|
||||
const applied = state?.lastAppliedColor;
|
||||
return applied &&
|
||||
current &&
|
||||
current.red === applied.red &&
|
||||
current.green === applied.green &&
|
||||
current.blue === applied.blue &&
|
||||
current.alpha === applied.alpha
|
||||
? state.base.alpha
|
||||
: (current?.alpha ?? 1);
|
||||
}
|
||||
|
||||
/** Apply the sector factor to a Cesium point/billboard without owning its base style. */
|
||||
export function applyCyberSonarPrimitive(
|
||||
visual,
|
||||
factor,
|
||||
active = true,
|
||||
sampler,
|
||||
property = 'color',
|
||||
opaque = false,
|
||||
) {
|
||||
if (!visual) return;
|
||||
const current = visual[property];
|
||||
let slots = primitiveColors.get(visual);
|
||||
let state = slots?.[property];
|
||||
const ownsCurrentColor =
|
||||
state &&
|
||||
current &&
|
||||
current.red === state.lastAppliedColor.red &&
|
||||
current.green === state.lastAppliedColor.green &&
|
||||
current.blue === state.lastAppliedColor.blue &&
|
||||
current.alpha === state.lastAppliedColor.alpha;
|
||||
if (!active) {
|
||||
// An owner refresh between the last paint and teardown wins over our cache.
|
||||
if (ownsCurrentColor)
|
||||
visual[property] = state.undefinedBase
|
||||
? undefined
|
||||
: copyColor(state.base);
|
||||
if (slots) delete slots[property];
|
||||
return;
|
||||
}
|
||||
if (!current && property !== 'color') return;
|
||||
const nextFactor = clamp01(factor);
|
||||
// Cesium setters copy into a stable internal Color instead of retaining the
|
||||
// assigned object. Compare its channels with our snapshot as well, so an
|
||||
// owner's in-place style refresh is still recognized as a new base.
|
||||
if (
|
||||
ownsCurrentColor &&
|
||||
Math.abs(nextFactor - state.lastAppliedFactor) <= 0.002 &&
|
||||
state.floor === sampler?.floor
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!ownsCurrentColor) {
|
||||
if (!state) {
|
||||
state = {
|
||||
base: copyColor(current) || { red: 1, green: 1, blue: 1, alpha: 1 },
|
||||
lastAppliedFactor: Number.NaN,
|
||||
};
|
||||
if (!slots) primitiveColors.set(visual, (slots = Object.create(null)));
|
||||
slots[property] = state;
|
||||
} else {
|
||||
state.base.red = current?.red ?? 1;
|
||||
state.base.green = current?.green ?? 1;
|
||||
state.base.blue = current?.blue ?? 1;
|
||||
state.base.alpha = current?.alpha ?? 1;
|
||||
}
|
||||
state.undefinedBase = current === undefined;
|
||||
}
|
||||
const next = state.lastAppliedColor || copyColor(state.base);
|
||||
if (!next) return;
|
||||
const tint =
|
||||
property === 'outlineColor' || property === 'backgroundColor'
|
||||
? state.base
|
||||
: sampler
|
||||
? sampler.tint(state.base, nextFactor, next)
|
||||
: cyberSonarTintColor(state.base, nextFactor);
|
||||
next.red = tint.red * (opaque ? nextFactor : 1);
|
||||
next.green = tint.green * (opaque ? nextFactor : 1);
|
||||
next.blue = tint.blue * (opaque ? nextFactor : 1);
|
||||
// Keep models in their owner's opaque/translucent render pass. Dimming RGB
|
||||
// avoids rebuilding model draw commands twice on every sector crossing.
|
||||
next.alpha = state.base.alpha * (opaque ? 1 : nextFactor);
|
||||
visual[property] = next;
|
||||
state.appliedColor = visual[property];
|
||||
state.lastAppliedColor = next;
|
||||
state.lastAppliedFactor = nextFactor;
|
||||
state.floor = sampler?.floor;
|
||||
}
|
||||
|
||||
/** One settings read and one clock/viewport calculation per paint, not contact. */
|
||||
export function createCyberSonarSampler(
|
||||
width,
|
||||
height,
|
||||
now,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
if (now === undefined) now = globalThis.performance?.now?.() ?? 0;
|
||||
const settings = readCyberSonarSettings(root);
|
||||
const floor = settings.opacity / 100;
|
||||
const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100;
|
||||
const radiusSquared = radius * radius;
|
||||
const angle = cyberSonarAngleDeg(now);
|
||||
const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42);
|
||||
return {
|
||||
floor,
|
||||
neutral: floor + (1 - floor) * 0.46,
|
||||
at(x, y) {
|
||||
if (
|
||||
!Number.isFinite(x) ||
|
||||
!Number.isFinite(y) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
)
|
||||
return 1;
|
||||
const dx = x - width / 2,
|
||||
dy = y - height / 2;
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
if (distanceSquared > radiusSquared) return floor;
|
||||
if (distanceSquared < radiusSquared * 0.000625) return 1;
|
||||
const delta =
|
||||
((((Math.atan2(dx, -dy) * 180) / Math.PI - angle) % 360) + 360) % 360;
|
||||
if (delta > settings.sector) return floor;
|
||||
return (
|
||||
floor + (1 - floor) * Math.min(1, (settings.sector - delta) / feather)
|
||||
);
|
||||
},
|
||||
label(factor) {
|
||||
return (1 + factor) * 0.5;
|
||||
},
|
||||
tint(base, factor, out) {
|
||||
const coverage =
|
||||
floor >= 0.9999 ? 1 : clamp01((factor - floor) / (1 - floor));
|
||||
out.red = base.red * 0.28 + (0.62 + 0.38 * coverage) * 0.72;
|
||||
out.green = base.green * 0.28 + (0.68 + 0.16 * coverage) * 0.72;
|
||||
out.blue = base.blue * 0.28 + (0.72 + 0.12 * coverage) * 0.72;
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Remaining delay before the next contact-projection refresh is due. */
|
||||
export function cyberSonarFrameDelay(
|
||||
lastPaintAt,
|
||||
nowMs,
|
||||
intervalMs = CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
) {
|
||||
if (
|
||||
!Number.isFinite(lastPaintAt) ||
|
||||
!Number.isFinite(nowMs) ||
|
||||
!Number.isFinite(intervalMs) ||
|
||||
intervalMs <= 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, intervalMs - (nowMs - lastPaintAt));
|
||||
}
|
||||
|
||||
const datasetNumber = (root, key, fallback) => {
|
||||
const value = Number(root?.dataset?.[key]);
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
};
|
||||
|
||||
export function readCyberSonarSettings(
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
return {
|
||||
rings: Math.round(
|
||||
clamp(
|
||||
datasetNumber(root, 'cyberSonarRings', CYBER_SONAR_DEFAULTS.rings),
|
||||
3,
|
||||
12,
|
||||
),
|
||||
),
|
||||
range: Math.round(
|
||||
clamp(
|
||||
datasetNumber(root, 'cyberSonarRange', CYBER_SONAR_DEFAULTS.range),
|
||||
60,
|
||||
120,
|
||||
),
|
||||
),
|
||||
intensity: Math.round(
|
||||
clamp(
|
||||
datasetNumber(
|
||||
root,
|
||||
'cyberSonarIntensity',
|
||||
CYBER_SONAR_DEFAULTS.intensity,
|
||||
),
|
||||
0,
|
||||
100,
|
||||
),
|
||||
),
|
||||
opacity: Math.round(
|
||||
clamp(
|
||||
datasetNumber(root, 'cyberSonarOpacity', CYBER_SONAR_DEFAULTS.opacity),
|
||||
35,
|
||||
100,
|
||||
),
|
||||
),
|
||||
sector: Math.round(
|
||||
clamp(
|
||||
datasetNumber(root, 'cyberSonarSector', CYBER_SONAR_DEFAULTS.sector),
|
||||
8,
|
||||
60,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCyberSonarSettings(
|
||||
settings,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
if (!root?.dataset || !root?.style) return readCyberSonarSettings(root);
|
||||
const next = {
|
||||
...readCyberSonarSettings(root),
|
||||
...(settings || {}),
|
||||
};
|
||||
// Ignore stale session input from the removed effect selector.
|
||||
delete next.mode;
|
||||
delete root.dataset.cyberSonarMode;
|
||||
next.rings = Math.round(
|
||||
clamp(Number(next.rings) || CYBER_SONAR_DEFAULTS.rings, 3, 12),
|
||||
);
|
||||
next.range = Math.round(
|
||||
clamp(Number(next.range) || CYBER_SONAR_DEFAULTS.range, 60, 120),
|
||||
);
|
||||
next.intensity = Math.round(
|
||||
clamp(
|
||||
Number.isFinite(Number(next.intensity))
|
||||
? Number(next.intensity)
|
||||
: CYBER_SONAR_DEFAULTS.intensity,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
);
|
||||
next.opacity = Math.round(
|
||||
clamp(
|
||||
Number.isFinite(Number(next.opacity))
|
||||
? Number(next.opacity)
|
||||
: CYBER_SONAR_DEFAULTS.opacity,
|
||||
35,
|
||||
100,
|
||||
),
|
||||
);
|
||||
next.sector = Math.round(
|
||||
clamp(Number(next.sector) || CYBER_SONAR_DEFAULTS.sector, 8, 60),
|
||||
);
|
||||
root.dataset.cyberSonarRings = String(next.rings);
|
||||
root.dataset.cyberSonarRange = String(next.range);
|
||||
root.dataset.cyberSonarIntensity = String(next.intensity);
|
||||
root.dataset.cyberSonarOpacity = String(next.opacity);
|
||||
root.dataset.cyberSonarSector = String(next.sector);
|
||||
root.style.setProperty('--cyber-sonar-ring-step', `${100 / next.rings}%`);
|
||||
root.style.setProperty(
|
||||
'--cyber-sonar-size-vw',
|
||||
`${(80 * next.range) / 100}vw`,
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--cyber-sonar-size-vh',
|
||||
`${(104 * next.range) / 100}vh`,
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--cyber-sonar-visual-intensity',
|
||||
String(0.22 + 0.5 * (next.intensity / 100)),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--cyber-sonar-sector-quarter',
|
||||
`${next.sector * 0.25}deg`,
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--cyber-sonar-sector-middle',
|
||||
`${next.sector * 0.6}deg`,
|
||||
);
|
||||
root.style.setProperty('--cyber-sonar-sector-width', `${next.sector}deg`);
|
||||
return next;
|
||||
}
|
||||
|
||||
const cyberSonarDimFactor = (root = globalThis.document?.documentElement) =>
|
||||
readCyberSonarSettings(root).opacity / 100;
|
||||
|
||||
const cyberSonarLabelDimFactor = (
|
||||
root = globalThis.document?.documentElement,
|
||||
) => {
|
||||
const contactFloor = cyberSonarDimFactor(root);
|
||||
return Number((contactFloor + (1 - contactFloor) * 0.5).toFixed(4));
|
||||
};
|
||||
|
||||
export function cyberSonarNeutralIntensity(
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
const dimFactor = cyberSonarDimFactor(root);
|
||||
return dimFactor + (1 - dimFactor) * 0.46;
|
||||
}
|
||||
|
||||
export function cyberSonarTintColor(
|
||||
baseColor,
|
||||
contactIntensity,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
const dimFactor = cyberSonarDimFactor(root);
|
||||
const remainingRange = 1 - dimFactor;
|
||||
const coverage =
|
||||
remainingRange <= 0.0001
|
||||
? 1
|
||||
: clamp01((contactIntensity - dimFactor) / remainingRange);
|
||||
const target = {
|
||||
red: 0.62 + 0.38 * coverage,
|
||||
green: 0.68 + 0.16 * coverage,
|
||||
blue: 0.72 + 0.12 * coverage,
|
||||
};
|
||||
const retain = 0.28;
|
||||
return {
|
||||
red: (baseColor?.red ?? 1) * retain + target.red * (1 - retain),
|
||||
green: (baseColor?.green ?? 1) * retain + target.green * (1 - retain),
|
||||
blue: (baseColor?.blue ?? 1) * retain + target.blue * (1 - retain),
|
||||
alpha: baseColor?.alpha ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPhaseMs = (nowMs) => {
|
||||
if (!Number.isFinite(nowMs)) return 0;
|
||||
return (
|
||||
((nowMs % CYBER_SONAR_PERIOD_MS) + CYBER_SONAR_PERIOD_MS) %
|
||||
CYBER_SONAR_PERIOD_MS
|
||||
);
|
||||
};
|
||||
|
||||
/** Clockwise sweep heading, where 0 degrees is the top of the keyhole. */
|
||||
export function cyberSonarAngleDeg(nowMs) {
|
||||
return (normalizedPhaseMs(nowMs) / CYBER_SONAR_PERIOD_MS) * 360;
|
||||
}
|
||||
|
||||
/** Negative CSS animation delay that synchronizes the sector to the shared clock. */
|
||||
export function cyberSonarAnimationDelay(nowMs) {
|
||||
return `${-normalizedPhaseMs(nowMs)}ms`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brightness/alpha multiplier for one screen-projected contact.
|
||||
* Invalid projections fail open so a render edge case never hides a contact.
|
||||
*/
|
||||
export function cyberSonarIntensityAtScreenPoint(
|
||||
x,
|
||||
y,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
nowMs,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
if (
|
||||
![x, y, viewportWidth, viewportHeight, nowMs].every(Number.isFinite) ||
|
||||
viewportWidth <= 0 ||
|
||||
viewportHeight <= 0
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const centerX = viewportWidth / 2;
|
||||
const centerY = viewportHeight / 2;
|
||||
const dx = x - centerX;
|
||||
const dy = y - centerY;
|
||||
const settings = readCyberSonarSettings(root);
|
||||
const dimFactor = cyberSonarDimFactor(root);
|
||||
const radius =
|
||||
Math.min(viewportWidth * 0.4, viewportHeight * 0.52) *
|
||||
(settings.range / 100);
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance > radius) return dimFactor;
|
||||
if (distance < radius * 0.025) return 1;
|
||||
|
||||
const pointAngle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
|
||||
const delta = (pointAngle - cyberSonarAngleDeg(nowMs) + 360) % 360;
|
||||
const sweepDeg = settings.sector;
|
||||
const featherDeg = Math.min(CYBER_SONAR_FEATHER_DEG, sweepDeg * 0.42);
|
||||
if (delta > sweepDeg) return dimFactor;
|
||||
|
||||
const featherStart = sweepDeg - featherDeg;
|
||||
const coverage =
|
||||
delta <= featherStart ? 1 : clamp01((sweepDeg - delta) / featherDeg);
|
||||
return dimFactor + (1 - dimFactor) * coverage;
|
||||
}
|
||||
|
||||
/** Preserve label legibility while keeping it synchronized with the stronger contact sweep. */
|
||||
export function cyberSonarLabelIntensity(
|
||||
contactIntensity,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
const dimFactor = cyberSonarDimFactor(root);
|
||||
const labelDimFactor = cyberSonarLabelDimFactor(root);
|
||||
const remainingRange = 1 - dimFactor;
|
||||
if (remainingRange <= 0.0001) return 1;
|
||||
const normalized = clamp01((contactIntensity - dimFactor) / remainingRange);
|
||||
return labelDimFactor + (1 - labelDimFactor) * normalized;
|
||||
}
|
||||
|
||||
export function isCyberSonarEnabled(
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
return root?.dataset?.cyberSonar !== 'off';
|
||||
}
|
||||
|
||||
export function setCyberSonarEnabled(
|
||||
enabled,
|
||||
root = globalThis.document?.documentElement,
|
||||
) {
|
||||
if (!root?.dataset) return false;
|
||||
root.dataset.cyberSonar = enabled ? 'on' : 'off';
|
||||
if (root.dataset.uiTheme === 'cyber') {
|
||||
applyCyberSonarThemePhase(root, enabled);
|
||||
}
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Whether the map-mode sweep should alter flight presentation. */
|
||||
export function isCyberSonarActive({
|
||||
root = globalThis.document?.documentElement,
|
||||
body = globalThis.document?.body,
|
||||
hud,
|
||||
reducedMotion,
|
||||
} = {}) {
|
||||
// Keep optional method calls in the body: production downleveling of nested
|
||||
// parameter initializers can otherwise lose the method's receiver binding.
|
||||
if (hud === undefined)
|
||||
hud = globalThis.document?.getElementById?.('intel-hud');
|
||||
if (reducedMotion === undefined)
|
||||
reducedMotion =
|
||||
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ??
|
||||
false;
|
||||
return !!(
|
||||
root?.dataset?.uiTheme === 'cyber' &&
|
||||
isCyberSonarEnabled(root) &&
|
||||
hud?.classList?.contains?.('active') &&
|
||||
!body?.classList?.contains?.('cockpit-mode') &&
|
||||
!reducedMotion
|
||||
);
|
||||
}
|
||||
|
||||
export function isCyberMapThemeActive({
|
||||
root = globalThis.document?.documentElement,
|
||||
body = globalThis.document?.body,
|
||||
hud,
|
||||
} = {}) {
|
||||
if (hud === undefined)
|
||||
hud = globalThis.document?.getElementById?.('intel-hud');
|
||||
return !!(
|
||||
root?.dataset?.uiTheme === 'cyber' &&
|
||||
hud?.classList?.contains?.('active') &&
|
||||
!body?.classList?.contains?.('cockpit-mode')
|
||||
);
|
||||
}
|
||||
|
||||
// Unsupported engines retain their native contact rendering, without a dimmer
|
||||
// fallback. Decorative rings remain independent from the contact adapter.
|
||||
export function isCyberContactThemeActive(options = {}) {
|
||||
const root = options.root ?? globalThis.document?.documentElement;
|
||||
return (
|
||||
isCyberMapThemeActive(options) &&
|
||||
root?.dataset?.cyberSonarGpu !== 'unsupported'
|
||||
);
|
||||
}
|
||||
|
||||
export function isCyberContactSonarActive(options = {}) {
|
||||
return isCyberContactThemeActive(options) && isCyberSonarActive(options);
|
||||
}
|
||||
|
||||
/** Set or clear the shared visual phase used by the sector and contact sweep. */
|
||||
export function applyCyberSonarThemePhase(root, enabled, nowMs) {
|
||||
if (!root?.style) return;
|
||||
if (!enabled || !isCyberSonarEnabled(root)) {
|
||||
root.style.removeProperty?.('--cyber-sonar-angle');
|
||||
root.style.removeProperty?.('--cyber-sonar-phase-delay');
|
||||
return;
|
||||
}
|
||||
const clock = Number.isFinite(nowMs)
|
||||
? nowMs
|
||||
: (globalThis.performance?.now?.() ?? 0);
|
||||
// The visual sweep is compositor-animated. Writing a custom property on the
|
||||
// document root every frame invalidates styles across the entire HUD, so JS
|
||||
// only seeds the shared clock when the theme or sonar state changes.
|
||||
root.style.removeProperty?.('--cyber-sonar-angle');
|
||||
const currentDelay = root.style
|
||||
.getPropertyValue?.('--cyber-sonar-phase-delay')
|
||||
?.trim();
|
||||
// setVariant() may be called repeatedly with the already-active layout.
|
||||
// Preserve the running animation's original timeline in that case; changing
|
||||
// its negative delay after it has started would shift the sector off the
|
||||
// absolute clock used by contact highlighting.
|
||||
if (currentDelay) return;
|
||||
root.style.setProperty?.(
|
||||
'--cyber-sonar-phase-delay',
|
||||
cyberSonarAnimationDelay(clock),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { transformWithEsbuild } from 'vite';
|
||||
import { BillboardCollection, Color, PointPrimitive } from 'cesium';
|
||||
import {
|
||||
syncCyberSonarAnimation,
|
||||
applyCyberSonarPrimitive,
|
||||
cyberSonarBaseAlpha,
|
||||
applyCyberSonarThemePhase,
|
||||
cyberSonarAngleDeg,
|
||||
cyberSonarAnimationDelay,
|
||||
cyberSonarFrameDelay,
|
||||
cyberSonarIntensityAtScreenPoint,
|
||||
cyberSonarLabelIntensity,
|
||||
cyberSonarTintColor,
|
||||
applyCyberSonarSettings,
|
||||
CYBER_SONAR_DIM_FACTOR,
|
||||
CYBER_SONAR_LABEL_DIM_FACTOR,
|
||||
CYBER_SONAR_PERIOD_MS,
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
isCyberSonarEnabled,
|
||||
isCyberSonarActive,
|
||||
setCyberSonarEnabled,
|
||||
} from './cyberSonar.js';
|
||||
|
||||
test('production-transformed sonar defaults retain the document method receiver', async () => {
|
||||
const source = await readFile(
|
||||
new URL('./cyberSonar.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const { code } = await transformWithEsbuild(source, 'cyberSonar.js', {
|
||||
target: 'es2019',
|
||||
minify: true,
|
||||
});
|
||||
const compiled = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||
);
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
const hud = { classList: { contains: (name) => name === 'active' } };
|
||||
const document = {
|
||||
documentElement: { dataset: { uiTheme: 'cyber' } },
|
||||
body: { classList: { contains: () => false } },
|
||||
getElementById(id) {
|
||||
assert.equal(this, document);
|
||||
assert.equal(id, 'intel-hud');
|
||||
return hud;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: document,
|
||||
});
|
||||
try {
|
||||
assert.equal(compiled.isCyberSonarActive(), true);
|
||||
assert.equal(compiled.isCyberMapThemeActive(), true);
|
||||
assert.ok(
|
||||
Number.isFinite(compiled.createCyberSonarSampler(800, 600).at(400, 300)),
|
||||
);
|
||||
document.documentElement.dataset.uiTheme = 'tactical';
|
||||
assert.equal(compiled.isCyberSonarActive(), false);
|
||||
assert.equal(compiled.isCyberMapThemeActive(), false);
|
||||
} finally {
|
||||
if (previous) Object.defineProperty(globalThis, 'document', previous);
|
||||
else delete globalThis.document;
|
||||
}
|
||||
});
|
||||
|
||||
test('hue-only aircraft restyles retain owner alpha across sonar opacity changes', () => {
|
||||
const collection = new BillboardCollection();
|
||||
const visual = collection.add({ color: Color.WHITE.withAlpha(0.8) });
|
||||
try {
|
||||
for (const opacity of [0.84, 0.35, 0.81, 1, 0.84]) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
|
||||
applyCyberSonarPrimitive(visual, opacity);
|
||||
assert.equal(visual.color.alpha, 0.8 * opacity);
|
||||
}
|
||||
}
|
||||
// A true owner freshness update must not be mistaken for our old alpha.
|
||||
visual.color = Color.YELLOW.withAlpha(0.4);
|
||||
assert.equal(cyberSonarBaseAlpha(visual), 0.4);
|
||||
applyCyberSonarPrimitive(visual, 0.35);
|
||||
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
|
||||
applyCyberSonarPrimitive(visual, 0.84);
|
||||
applyCyberSonarPrimitive(visual, 1, false);
|
||||
assert.ok(Color.equals(visual.color, Color.CYAN.withAlpha(0.4)));
|
||||
} finally {
|
||||
collection.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('the Cyber sonar clock advances clockwise and wraps cleanly', () => {
|
||||
assert.equal(cyberSonarAngleDeg(0), 0);
|
||||
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS / 4), 90);
|
||||
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS), 0);
|
||||
assert.equal(cyberSonarAnimationDelay(CYBER_SONAR_PERIOD_MS / 2), '-2600ms');
|
||||
});
|
||||
|
||||
test('contact refresh cadence waits only until the next bounded sonar frame', () => {
|
||||
assert.equal(CYBER_SONAR_RENDER_INTERVAL_MS, 80);
|
||||
assert.equal(cyberSonarFrameDelay(1_000, 1_000), 80);
|
||||
assert.equal(cyberSonarFrameDelay(1_000, 1_035), 45);
|
||||
assert.equal(cyberSonarFrameDelay(1_000, 1_080), 0);
|
||||
assert.equal(cyberSonarFrameDelay(Number.NEGATIVE_INFINITY, 1_000), 0);
|
||||
});
|
||||
|
||||
test('the sweep brightens contacts inside its sector and dims the remainder', () => {
|
||||
const width = 1_000;
|
||||
const height = 800;
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
assert.equal(
|
||||
cyberSonarIntensityAtScreenPoint(centerX, centerY - 200, width, height, 0),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
cyberSonarIntensityAtScreenPoint(centerX + 200, centerY, width, height, 0),
|
||||
CYBER_SONAR_DIM_FACTOR,
|
||||
);
|
||||
assert.equal(
|
||||
cyberSonarIntensityAtScreenPoint(
|
||||
centerX + 200,
|
||||
centerY,
|
||||
width,
|
||||
height,
|
||||
CYBER_SONAR_PERIOD_MS / 4,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
cyberSonarIntensityAtScreenPoint(centerX, centerY - 401, width, height, 0),
|
||||
CYBER_SONAR_DIM_FACTOR,
|
||||
);
|
||||
assert.equal(
|
||||
cyberSonarIntensityAtScreenPoint(Number.NaN, 0, width, height, 0),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('the sonar activates only for a visible Cyber map HUD with motion enabled', () => {
|
||||
const root = { dataset: { uiTheme: 'cyber' } };
|
||||
const classes = (...values) => ({
|
||||
contains: (value) => values.includes(value),
|
||||
});
|
||||
assert.equal(
|
||||
isCyberSonarActive({
|
||||
root,
|
||||
body: { classList: classes() },
|
||||
hud: { classList: classes('active') },
|
||||
reducedMotion: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCyberSonarActive({
|
||||
root,
|
||||
body: { classList: classes('cockpit-mode') },
|
||||
hud: { classList: classes('active') },
|
||||
reducedMotion: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCyberSonarActive({
|
||||
root,
|
||||
body: { classList: classes() },
|
||||
hud: { classList: classes('active') },
|
||||
reducedMotion: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
root.dataset.cyberSonar = 'off';
|
||||
assert.equal(
|
||||
isCyberSonarActive({
|
||||
root,
|
||||
body: { classList: classes() },
|
||||
hud: { classList: classes('active') },
|
||||
reducedMotion: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('labels keep a more readable baseline while tracking the same sweep', () => {
|
||||
assert.equal(CYBER_SONAR_DIM_FACTOR, 0.84);
|
||||
assert.equal(CYBER_SONAR_LABEL_DIM_FACTOR, 0.92);
|
||||
assert.equal(
|
||||
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR),
|
||||
CYBER_SONAR_LABEL_DIM_FACTOR,
|
||||
);
|
||||
assert.equal(cyberSonarLabelIntensity(1), 1);
|
||||
assert.ok(
|
||||
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR) > CYBER_SONAR_DIM_FACTOR,
|
||||
);
|
||||
});
|
||||
|
||||
test('the opacity setting controls every contact floor and preserves brighter labels', () => {
|
||||
const values = new Map();
|
||||
const root = {
|
||||
dataset: {},
|
||||
style: { setProperty: (name, value) => values.set(name, value) },
|
||||
};
|
||||
const settings = applyCyberSonarSettings({ opacity: 62 }, root);
|
||||
assert.equal(settings.opacity, 62);
|
||||
assert.equal(root.dataset.cyberSonarOpacity, '62');
|
||||
assert.equal(
|
||||
cyberSonarIntensityAtScreenPoint(900, 400, 1_000, 800, 0, root),
|
||||
0.62,
|
||||
);
|
||||
assert.equal(cyberSonarLabelIntensity(0.62, root), 0.81);
|
||||
assert.equal(cyberSonarLabelIntensity(1, root), 1);
|
||||
|
||||
applyCyberSonarSettings({ opacity: 100 }, root);
|
||||
assert.equal(cyberSonarLabelIntensity(1, root), 1);
|
||||
assert.deepEqual(
|
||||
cyberSonarTintColor({ red: 1, green: 1, blue: 1, alpha: 1 }, 1, root),
|
||||
{ red: 1, green: 0.8848, blue: 0.8848, alpha: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
test('the sonar control defaults on and records explicit operator state', () => {
|
||||
const root = { dataset: {} };
|
||||
assert.equal(isCyberSonarEnabled(root), true);
|
||||
assert.equal(setCyberSonarEnabled(false, root), false);
|
||||
assert.equal(root.dataset.cyberSonar, 'off');
|
||||
assert.equal(isCyberSonarEnabled(root), false);
|
||||
assert.equal(setCyberSonarEnabled(true, root), true);
|
||||
assert.equal(root.dataset.cyberSonar, 'on');
|
||||
});
|
||||
|
||||
test('real Cesium points and billboards recover after repeated low/high opacity cycles', () => {
|
||||
const collection = new BillboardCollection();
|
||||
const base = new Color(0.7, 0.5, 0.25, 0.8);
|
||||
const visuals = [
|
||||
new PointPrimitive({ color: base }),
|
||||
collection.add({ color: base }),
|
||||
];
|
||||
try {
|
||||
for (const visual of visuals) {
|
||||
for (const factor of [0.35, 1, 0.5, 1, 0.35]) {
|
||||
for (let frame = 0; frame < 60; frame++) {
|
||||
applyCyberSonarPrimitive(visual, factor);
|
||||
}
|
||||
assert.equal(visual.color.alpha, base.alpha * factor);
|
||||
}
|
||||
// Owner setters mutate the same Cesium Color object, even when the new
|
||||
// alpha equals the last sonar output. Its new RGB must become the base.
|
||||
const refreshed = new Color(0.2, 0.8, 0.4, visual.color.alpha);
|
||||
visual.color = refreshed;
|
||||
applyCyberSonarPrimitive(visual, 0.5);
|
||||
assert.equal(visual.color.alpha, refreshed.alpha * 0.5);
|
||||
applyCyberSonarPrimitive(visual, 1, false);
|
||||
assert.ok(Color.equals(visual.color, refreshed));
|
||||
}
|
||||
} finally {
|
||||
collection.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('generic Cesium primitives dim, accept owner style refreshes, and restore', () => {
|
||||
const makeColor = (alpha, red = 1) => ({
|
||||
red,
|
||||
green: 0.5,
|
||||
blue: 0.25,
|
||||
alpha,
|
||||
clone() {
|
||||
return makeColor(this.alpha, this.red);
|
||||
},
|
||||
});
|
||||
const visual = { color: makeColor(0.8) };
|
||||
applyCyberSonarPrimitive(visual, 0.25, true);
|
||||
assert.equal(visual.color.alpha, 0.2);
|
||||
const paintedColor = visual.color;
|
||||
applyCyberSonarPrimitive(visual, 0.25, true);
|
||||
assert.equal(visual.color, paintedColor);
|
||||
visual.color = makeColor(0.6, 0.7);
|
||||
applyCyberSonarPrimitive(visual, 0.5, true);
|
||||
assert.equal(visual.color.alpha, 0.3);
|
||||
assert.notEqual(visual.color.red, 0.7);
|
||||
applyCyberSonarPrimitive(visual, 1, false);
|
||||
assert.equal(visual.color.alpha, 0.6);
|
||||
assert.equal(visual.color.red, 0.7);
|
||||
});
|
||||
|
||||
test('Cyber contact tint moves from cool-muted to warm-bright with the sector', () => {
|
||||
const root = { dataset: { cyberSonarIntensity: '70' } };
|
||||
const base = { red: 0.2, green: 0.8, blue: 1, alpha: 0.75 };
|
||||
const dim = cyberSonarTintColor(base, CYBER_SONAR_DIM_FACTOR, root);
|
||||
const bright = cyberSonarTintColor(base, 1, root);
|
||||
assert.ok(bright.red > dim.red);
|
||||
assert.ok(bright.green > dim.green);
|
||||
assert.equal(bright.alpha, 0.75);
|
||||
});
|
||||
|
||||
test('unswept white map symbols retain a bright readable Cyber tint', () => {
|
||||
const root = { dataset: { cyberSonarIntensity: '70' } };
|
||||
const dim = cyberSonarTintColor(
|
||||
{ red: 1, green: 1, blue: 1, alpha: 1 },
|
||||
CYBER_SONAR_DIM_FACTOR,
|
||||
root,
|
||||
);
|
||||
assert.ok(dim.red >= 0.72);
|
||||
assert.ok(dim.green >= 0.76);
|
||||
assert.ok(dim.blue >= 0.79);
|
||||
assert.equal(dim.alpha, 1);
|
||||
});
|
||||
|
||||
test('the sweep corrects delayed starts and reacquires restarted animations without per-frame writes', () => {
|
||||
let queries = 0,
|
||||
writes = 0;
|
||||
const makeAnimation = () => ({
|
||||
animationName: 'cyber-sonar-sweep',
|
||||
playState: 'running',
|
||||
startTime: 1800,
|
||||
effect: {
|
||||
updateTiming(value) {
|
||||
assert.deepEqual(value, { delay: 0 });
|
||||
writes++;
|
||||
},
|
||||
},
|
||||
});
|
||||
let animation = makeAnimation();
|
||||
let element = {
|
||||
isConnected: true,
|
||||
getAnimations() {
|
||||
queries++;
|
||||
return [animation];
|
||||
},
|
||||
};
|
||||
const root = { querySelector: () => element };
|
||||
syncCyberSonarAnimation(root);
|
||||
assert.equal(animation.startTime, 0);
|
||||
for (let i = 0; i < 100; i++) syncCyberSonarAnimation(root);
|
||||
assert.equal(queries, 1);
|
||||
assert.equal(writes, 1);
|
||||
animation.playState = 'idle';
|
||||
animation = makeAnimation();
|
||||
syncCyberSonarAnimation(root);
|
||||
assert.equal(animation.startTime, 0);
|
||||
assert.equal(writes, 2);
|
||||
element.isConnected = false;
|
||||
animation = makeAnimation();
|
||||
element = { isConnected: true, getAnimations: () => [animation] };
|
||||
syncCyberSonarAnimation(root);
|
||||
assert.equal(animation.startTime, 0);
|
||||
assert.equal(writes, 3);
|
||||
assert.doesNotThrow(() => syncCyberSonarAnimation({}));
|
||||
});
|
||||
|
||||
test('the selected Cyber theme seeds and releases the compositor sweep phase', () => {
|
||||
const values = new Map();
|
||||
const root = {
|
||||
dataset: {},
|
||||
style: {
|
||||
setProperty: (name, value) => values.set(name, value),
|
||||
removeProperty: (name) => values.delete(name),
|
||||
getPropertyValue: (name) => values.get(name) || '',
|
||||
},
|
||||
};
|
||||
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 4);
|
||||
assert.equal(values.has('--cyber-sonar-angle'), false);
|
||||
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
|
||||
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 2);
|
||||
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
|
||||
applyCyberSonarThemePhase(root, false, 0);
|
||||
assert.equal(values.has('--cyber-sonar-angle'), false);
|
||||
assert.equal(values.has('--cyber-sonar-phase-delay'), false);
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Narrow, version-tested engine adapter. Cesium has no public custom shader
|
||||
// option for native point/billboard collections. Do not patch its prototypes or
|
||||
// access EntityCluster's private collections. An engine upgrade must revalidate
|
||||
// this adapter; unsupported versions keep the original commands unchanged.
|
||||
export const CYBER_SONAR_CESIUM_VERSION = '1.138.0';
|
||||
|
||||
// Follow every public DrawCommand setter, including pick/shadow/culling flags.
|
||||
// Copying native.dirty would keep derived pipelines dirty forever: the scene
|
||||
// consumes our command, so it never clears that flag on the native command.
|
||||
const commandProperties = Object.entries(
|
||||
Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype),
|
||||
)
|
||||
.filter(
|
||||
([key, descriptor]) =>
|
||||
descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap',
|
||||
)
|
||||
.map(([key]) => key);
|
||||
|
||||
export function syncCyberSonarCommand(
|
||||
native,
|
||||
record,
|
||||
program,
|
||||
uniforms,
|
||||
labelBackground,
|
||||
) {
|
||||
if (!record.command) {
|
||||
record.command = new Cesium.DrawCommand();
|
||||
record.uniformMap = {
|
||||
...uniforms,
|
||||
u_gevSonarLabelBackground: () => record.labelBackground,
|
||||
};
|
||||
}
|
||||
const command = record.command;
|
||||
record.labelBackground = labelBackground;
|
||||
// Uniform values (angle/viewport/etc.) change through stable callbacks, not
|
||||
// new maps/functions. Owner callback replacement and removal still propagate.
|
||||
for (const key in record.uniformMap) {
|
||||
if (
|
||||
!(key in uniforms) &&
|
||||
key !== 'u_gevSonarLabelBackground' &&
|
||||
!(key in native.uniformMap)
|
||||
) {
|
||||
delete record.uniformMap[key];
|
||||
command.dirty = true;
|
||||
}
|
||||
}
|
||||
for (const key in native.uniformMap) {
|
||||
if (record.uniformMap[key] !== native.uniformMap[key]) {
|
||||
record.uniformMap[key] = native.uniformMap[key];
|
||||
command.dirty = true;
|
||||
}
|
||||
}
|
||||
for (const key of commandProperties) command[key] = native[key];
|
||||
command.shaderProgram = program;
|
||||
command.uniformMap = record.uniformMap;
|
||||
return command;
|
||||
}
|
||||
|
||||
export function sonarVertexSource(source, kind) {
|
||||
const text = source.sources.join('\n');
|
||||
const attribute =
|
||||
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
|
||||
const lowAttribute =
|
||||
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
|
||||
if (
|
||||
!text.includes(attribute) ||
|
||||
!text.includes(lowAttribute) ||
|
||||
!text.includes('v_color') ||
|
||||
!/void\s+main\s*\(\s*\)/.test(text)
|
||||
)
|
||||
return null;
|
||||
const next = source.clone();
|
||||
next.sources = [
|
||||
text.replace(/void\s+main\s*\(\s*\)/, 'void gev_sonar_main()'),
|
||||
`
|
||||
uniform vec4 u_gevSonar; // angle, radius, opacity floor, sweep enabled
|
||||
uniform vec2 u_gevSonarViewport;
|
||||
uniform float u_gevSonarSector;
|
||||
uniform float u_gevSonarLabelBackground;
|
||||
void main() {
|
||||
gev_sonar_main();
|
||||
// All corners/glyphs share the contact anchor, before native screen offsets.
|
||||
// Leave gl_Position untouched so appearance never changes geometry or picks.
|
||||
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
|
||||
if (anchor.w <= 0.0) return;
|
||||
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
|
||||
float floorValue = u_gevSonar.z;
|
||||
float factor = floorValue + (1.0 - floorValue) * 0.46;
|
||||
if (u_gevSonar.w > 0.5) {
|
||||
float distanceSquared = dot(pixel, pixel);
|
||||
float radiusSquared = u_gevSonar.y * u_gevSonar.y;
|
||||
float delta = mod(degrees(atan(pixel.x, pixel.y)) - u_gevSonar.x + 360.0, 360.0);
|
||||
if (delta > 359.9999) delta = 0.0;
|
||||
factor = floorValue;
|
||||
if (distanceSquared < radiusSquared * 0.000625) factor = 1.0;
|
||||
else if (distanceSquared <= radiusSquared && delta <= u_gevSonarSector)
|
||||
factor += (1.0 - floorValue) * min(1.0, (u_gevSonarSector - delta) / min(10.0, u_gevSonarSector * 0.42));
|
||||
}
|
||||
#ifdef SDF
|
||||
factor = (1.0 + factor) * 0.5;
|
||||
#else
|
||||
if (u_gevSonarLabelBackground > 0.5) factor = (1.0 + factor) * 0.5;
|
||||
#endif
|
||||
float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0);
|
||||
if (u_gevSonarLabelBackground < 0.5)
|
||||
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
|
||||
// Apply before native fragment pass classification, never after main().
|
||||
v_color.a *= factor;
|
||||
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
|
||||
}
|
||||
`,
|
||||
];
|
||||
return next;
|
||||
}
|
||||
|
||||
// Use native batch bounds, never individual contacts, for idle render demand.
|
||||
// Like Cesium's frustum builder, exclude the far plane (it fits multiple frusta).
|
||||
export function isCyberSonarCommandVisible(command, frameState, culling) {
|
||||
const bounds = command.boundingVolume;
|
||||
if (!bounds || !command.cull) return true;
|
||||
culling ??= new Cesium.CullingVolume(
|
||||
frameState.cullingVolume.planes.slice(0, 5),
|
||||
);
|
||||
if (culling.computeVisibility(bounds) === Cesium.Intersect.OUTSIDE)
|
||||
return false;
|
||||
return !(
|
||||
frameState.mode === Cesium.SceneMode.SCENE3D &&
|
||||
frameState.occluder &&
|
||||
command.occlude &&
|
||||
bounds.isOccluded(frameState.occluder)
|
||||
);
|
||||
}
|
||||
|
||||
/** Work scales with draw commands, not the number of contacts in a collection.
|
||||
* Source programs, colors, pick IDs, render states and other scenes stay owned
|
||||
* by Cesium. Only this scene's rendered command slice receives derived shaders.
|
||||
*/
|
||||
export function createCyberSonarGpu(scene, readFrame) {
|
||||
const root = scene.primitives;
|
||||
const original = root.update;
|
||||
const programs = new Map();
|
||||
let commands = new WeakMap();
|
||||
let disposed = false;
|
||||
const stats = {
|
||||
commands: 0,
|
||||
visibleCommands: 0,
|
||||
programs: 0,
|
||||
supported: Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION,
|
||||
error: null,
|
||||
};
|
||||
const uniforms = new Cesium.Cartesian4();
|
||||
const viewport = new Cesium.Cartesian2();
|
||||
let sector = 24;
|
||||
const sonarUniforms = {
|
||||
u_gevSonar: () => uniforms,
|
||||
u_gevSonarViewport: () => viewport,
|
||||
u_gevSonarSector: () => sector,
|
||||
};
|
||||
const culling = new Cesium.CullingVolume();
|
||||
const used = new Set();
|
||||
const replaced = [];
|
||||
const release = () => {
|
||||
for (const { program } of programs.values())
|
||||
if (!program.isDestroyed()) program.destroy();
|
||||
programs.clear();
|
||||
commands = new WeakMap();
|
||||
used.clear();
|
||||
replaced.length = 0;
|
||||
stats.programs = 0;
|
||||
};
|
||||
const wrapped = function (frameState) {
|
||||
const start = frameState.commandList.length;
|
||||
original.call(this, frameState);
|
||||
stats.commands = 0;
|
||||
stats.visibleCommands = 0;
|
||||
if (
|
||||
disposed ||
|
||||
!stats.supported ||
|
||||
!frameState.passes.render ||
|
||||
frameState.passes.pick
|
||||
)
|
||||
return;
|
||||
const frame = readFrame();
|
||||
if (!frame.enabled) {
|
||||
if (programs.size) release();
|
||||
return;
|
||||
}
|
||||
viewport.x = scene.drawingBufferWidth;
|
||||
viewport.y = scene.drawingBufferHeight;
|
||||
uniforms.x = frame.angle;
|
||||
uniforms.y =
|
||||
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
|
||||
uniforms.z = frame.opacity / 100;
|
||||
uniforms.w = frame.active ? 1 : 0;
|
||||
sector = frame.sector;
|
||||
used.clear();
|
||||
replaced.length = 0;
|
||||
if (frameState.cullingVolume)
|
||||
for (let i = 0; i < 5; i++)
|
||||
culling.planes[i] = frameState.cullingVolume.planes[i];
|
||||
try {
|
||||
for (let i = start; i < frameState.commandList.length; i++) {
|
||||
const native = frameState.commandList[i];
|
||||
const kind =
|
||||
native.owner instanceof Cesium.PointPrimitiveCollection
|
||||
? 'point'
|
||||
: native.owner instanceof Cesium.BillboardCollection
|
||||
? 'billboard'
|
||||
: null;
|
||||
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
|
||||
const source = native.shaderProgram;
|
||||
// Label backgrounds are non-SDF billboard batches. The version-tested
|
||||
// engine pick association distinguishes them without enumerating labels
|
||||
// or reaching into EntityCluster. One representative per draw batch;
|
||||
// Cesium's label glyph/background collections are homogeneous.
|
||||
const labelBackground =
|
||||
kind === 'billboard' &&
|
||||
!source.vertexShaderSource.defines.includes('SDF') &&
|
||||
native.owner.length > 0 &&
|
||||
native.owner.get(0).pickPrimitive instanceof Cesium.Label
|
||||
? 1
|
||||
: 0;
|
||||
let entry = programs.get(source);
|
||||
if (!entry) {
|
||||
const vertex = sonarVertexSource(source.vertexShaderSource, kind);
|
||||
if (!vertex) throw new Error('Unsupported native contact shader');
|
||||
const attributes = Object.fromEntries(
|
||||
Object.entries(source.vertexAttributes).map(([name, value]) => [
|
||||
name,
|
||||
value.index,
|
||||
]),
|
||||
);
|
||||
const program = Cesium.ShaderProgram.fromCache({
|
||||
context: frameState.context,
|
||||
vertexShaderSource: vertex,
|
||||
fragmentShaderSource: source.fragmentShaderSource,
|
||||
attributeLocations: attributes,
|
||||
});
|
||||
// Compile while errors can still leave the whole native command slice
|
||||
// intact, rather than stopping the viewer's render loop at draw time.
|
||||
try {
|
||||
void program.allUniforms;
|
||||
} catch (error) {
|
||||
program.destroy();
|
||||
throw error;
|
||||
}
|
||||
programs.set(source, (entry = { program }));
|
||||
}
|
||||
used.add(source);
|
||||
let record = commands.get(native);
|
||||
if (!record) commands.set(native, (record = {}));
|
||||
const derived = syncCyberSonarCommand(
|
||||
native,
|
||||
record,
|
||||
entry.program,
|
||||
sonarUniforms,
|
||||
labelBackground,
|
||||
);
|
||||
replaced.push(i, native);
|
||||
frameState.commandList[i] = derived;
|
||||
stats.commands++;
|
||||
if (isCyberSonarCommandVisible(native, frameState, culling))
|
||||
stats.visibleCommands++;
|
||||
}
|
||||
for (const [source, entry] of programs)
|
||||
if (!used.has(source)) {
|
||||
if (!entry.program.isDestroyed()) entry.program.destroy();
|
||||
programs.delete(source);
|
||||
}
|
||||
stats.programs = programs.size;
|
||||
} catch (error) {
|
||||
// Recover the original commands already replaced in this slice.
|
||||
for (let i = 0; i < replaced.length; i += 2)
|
||||
frameState.commandList[replaced[i]] = replaced[i + 1];
|
||||
stats.supported = false;
|
||||
stats.commands = 0;
|
||||
stats.visibleCommands = 0;
|
||||
stats.error = String(error?.message || error);
|
||||
release();
|
||||
}
|
||||
};
|
||||
root.update = wrapped;
|
||||
return {
|
||||
stats,
|
||||
destroy() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
if (root.update === wrapped && !root.isDestroyed?.())
|
||||
root.update = original;
|
||||
release();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarGpu,
|
||||
sonarVertexSource,
|
||||
CYBER_SONAR_CESIUM_VERSION,
|
||||
isCyberSonarCommandVisible,
|
||||
syncCyberSonarCommand,
|
||||
} from './cyberSonarGpu.js';
|
||||
|
||||
test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => {
|
||||
const native = new Cesium.DrawCommand({
|
||||
count: 6,
|
||||
uniformMap: { value: () => 1 },
|
||||
});
|
||||
const record = {},
|
||||
program = {},
|
||||
uniforms = { u_gevSonar: () => 42 };
|
||||
const derived = syncCyberSonarCommand(native, record, program, uniforms, 0);
|
||||
const map = derived.uniformMap;
|
||||
derived.dirty = false;
|
||||
derived.lastDirtyTime = 7;
|
||||
assert.equal(native.dirty, true);
|
||||
assert.equal(
|
||||
syncCyberSonarCommand(native, record, program, uniforms, 1),
|
||||
derived,
|
||||
);
|
||||
assert.equal(
|
||||
derived.dirty,
|
||||
false,
|
||||
'stale native dirty flag must not rebuild pipelines',
|
||||
);
|
||||
assert.equal(derived.lastDirtyTime, 7);
|
||||
assert.equal(derived.uniformMap, map);
|
||||
assert.equal(map.u_gevSonarLabelBackground(), 1);
|
||||
for (const [key, value] of Object.entries({
|
||||
count: 12,
|
||||
offset: 2,
|
||||
instanceCount: 3,
|
||||
pickId: 'pick',
|
||||
pass: Cesium.Pass.TRANSLUCENT,
|
||||
cull: false,
|
||||
occlude: false,
|
||||
castShadows: true,
|
||||
receiveShadows: true,
|
||||
pickOnly: true,
|
||||
renderState: {},
|
||||
vertexArray: {},
|
||||
modelMatrix: Cesium.Matrix4.IDENTITY,
|
||||
framebuffer: {},
|
||||
})) {
|
||||
derived.dirty = false;
|
||||
native[key] = value;
|
||||
syncCyberSonarCommand(native, record, program, uniforms, 0);
|
||||
assert.equal(derived[key], value, key);
|
||||
assert.equal(derived.dirty, true, key);
|
||||
}
|
||||
native.uniformMap.value = () => 2;
|
||||
syncCyberSonarCommand(native, record, program, uniforms, 0);
|
||||
assert.equal(map.value(), 2);
|
||||
native.uniformMap = { replacement: () => 3 };
|
||||
syncCyberSonarCommand(native, record, program, uniforms, 0);
|
||||
assert.equal(map.value, undefined);
|
||||
assert.equal(map.replacement(), 3);
|
||||
assert.equal(map.u_gevSonar(), 42);
|
||||
const nextProgram = {};
|
||||
syncCyberSonarCommand(native, record, nextProgram, uniforms, 0);
|
||||
assert.equal(derived.shaderProgram, nextProgram);
|
||||
assert.equal(native.shaderProgram, undefined);
|
||||
});
|
||||
|
||||
test('GPU shader transform is isolated, settings-driven, and precedes native fragment classification', () => {
|
||||
assert.equal(
|
||||
Cesium.VERSION,
|
||||
CYBER_SONAR_CESIUM_VERSION,
|
||||
'engine upgrades require GPU revalidation',
|
||||
);
|
||||
for (const [kind, attr, low] of [
|
||||
['point', 'positionHighAndSize', 'positionLowAndOutline'],
|
||||
['billboard', 'positionHighAndScale', 'positionLowAndRotation'],
|
||||
]) {
|
||||
const source = new Cesium.ShaderSource({
|
||||
defines: ['SDF'],
|
||||
sources: [
|
||||
`in vec4 ${attr}; in vec4 ${low}; out vec4 v_color; void main() { v_color=vec4(1.); }`,
|
||||
],
|
||||
});
|
||||
const original = source.sources[0];
|
||||
const next = sonarVertexSource(source, kind);
|
||||
assert.equal(source.sources[0], original);
|
||||
assert.notEqual(next, source);
|
||||
assert.deepEqual(next.defines, ['SDF']);
|
||||
assert.match(next.sources[0], /void gev_sonar_main/);
|
||||
assert.match(next.sources[1], /v_color.a \*= factor/);
|
||||
assert.match(next.sources[1], /u_gevSonarSector/);
|
||||
assert.match(next.sources[1], /floorValue \+ \(1.0 - floorValue\) \* 0.46/);
|
||||
assert.ok(
|
||||
next.sources[1].includes(
|
||||
`czm_translateRelativeToEye(${attr}.xyz, ${low}.xyz)`,
|
||||
),
|
||||
);
|
||||
assert.match(next.sources[1], /anchor.xy \/ anchor.w/);
|
||||
assert.doesNotMatch(next.sources[1], /gl_Position\s*[=\/]/);
|
||||
}
|
||||
assert.equal(
|
||||
sonarVertexSource(
|
||||
new Cesium.ShaderSource({ sources: ['void main() {}'] }),
|
||||
'point',
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('render demand follows batch bounds, including horizon culling, without far-plane rejection', () => {
|
||||
const planes = Array.from(
|
||||
{ length: 6 },
|
||||
(_, i) => new Cesium.Cartesian4(1, 0, 0, i),
|
||||
);
|
||||
const frame = {
|
||||
cullingVolume: { planes },
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
occluder: {},
|
||||
};
|
||||
let intersections = 0;
|
||||
let occluded = false;
|
||||
const command = {
|
||||
cull: true,
|
||||
occlude: true,
|
||||
boundingVolume: {
|
||||
intersectPlane(plane) {
|
||||
intersections++;
|
||||
return plane.distance === 5
|
||||
? Cesium.Intersect.OUTSIDE
|
||||
: Cesium.Intersect.INSIDE;
|
||||
},
|
||||
isOccluded() {
|
||||
return occluded;
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(isCyberSonarCommandVisible(command, frame), true);
|
||||
assert.equal(intersections, 5);
|
||||
occluded = true;
|
||||
assert.equal(isCyberSonarCommandVisible(command, frame), false);
|
||||
frame.mode = Cesium.SceneMode.SCENE2D;
|
||||
assert.equal(isCyberSonarCommandVisible(command, frame), true);
|
||||
command.boundingVolume.intersectPlane = () => Cesium.Intersect.OUTSIDE;
|
||||
assert.equal(isCyberSonarCommandVisible(command, frame), false);
|
||||
command.cull = false;
|
||||
assert.equal(isCyberSonarCommandVisible(command, frame), true);
|
||||
assert.equal(isCyberSonarCommandVisible({}, frame), true);
|
||||
});
|
||||
|
||||
test('adapter never walks contacts, touches other scenes, or transforms pick-only passes', () => {
|
||||
let calls = 0;
|
||||
const original = function (fs) {
|
||||
assert.equal(this, root);
|
||||
calls++;
|
||||
fs.commandList.push({ owner: {}, id: 'terrain' });
|
||||
};
|
||||
const root = { update: original };
|
||||
const other = { update: original };
|
||||
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
|
||||
enabled: false,
|
||||
}));
|
||||
const frame = { commandList: [], passes: { render: true, pick: false } };
|
||||
root.update(frame);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(frame.commandList[0].id, 'terrain');
|
||||
assert.equal(other.update, original);
|
||||
root.update({ commandList: [], passes: { render: false, pick: true } });
|
||||
assert.equal(calls, 2);
|
||||
adapter.destroy();
|
||||
adapter.destroy();
|
||||
assert.equal(root.update, original);
|
||||
});
|
||||
|
||||
test('unknown native shaders fail closed without losing native commands', () => {
|
||||
const points = new Cesium.PointPrimitiveCollection();
|
||||
const command = {
|
||||
owner: points,
|
||||
uniformMap: {},
|
||||
shaderProgram: {
|
||||
vertexShaderSource: new Cesium.ShaderSource({
|
||||
sources: ['void main() {}'],
|
||||
}),
|
||||
},
|
||||
};
|
||||
const root = {
|
||||
update(fs) {
|
||||
fs.commandList.push(command);
|
||||
},
|
||||
};
|
||||
const adapter = createCyberSonarGpu(
|
||||
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
|
||||
() => ({
|
||||
enabled: true,
|
||||
active: true,
|
||||
angle: 0,
|
||||
range: 100,
|
||||
opacity: 84,
|
||||
sector: 24,
|
||||
}),
|
||||
);
|
||||
const frame = { commandList: [], passes: { render: true } };
|
||||
root.update(frame);
|
||||
assert.equal(frame.commandList[0], command);
|
||||
assert.equal(adapter.stats.supported, false);
|
||||
assert.match(adapter.stats.error, /Unsupported native contact shader/);
|
||||
assert.equal(adapter.stats.programs, 0);
|
||||
adapter.destroy();
|
||||
points.destroy();
|
||||
});
|
||||
|
||||
test('program swaps, layer removal and late shader failure release only adapter resources', () => {
|
||||
const points = new Cesium.PointPrimitiveCollection();
|
||||
const source = () => ({
|
||||
vertexShaderSource: new Cesium.ShaderSource({
|
||||
sources: [
|
||||
'in vec4 positionHighAndSize; in vec4 positionLowAndOutline; out vec4 v_color; void main() {}',
|
||||
],
|
||||
}),
|
||||
vertexAttributes: {},
|
||||
fragmentShaderSource: new Cesium.ShaderSource({
|
||||
sources: ['void main() {}'],
|
||||
}),
|
||||
});
|
||||
const native = new Cesium.DrawCommand({
|
||||
owner: points,
|
||||
uniformMap: {},
|
||||
shaderProgram: source(),
|
||||
cull: false,
|
||||
vertexArray: { generation: 1 },
|
||||
pickId: 'native-pick',
|
||||
});
|
||||
const unknown = new Cesium.DrawCommand({
|
||||
owner: points,
|
||||
uniformMap: {},
|
||||
shaderProgram: {
|
||||
vertexShaderSource: new Cesium.ShaderSource({
|
||||
sources: ['void main() {}'],
|
||||
}),
|
||||
},
|
||||
});
|
||||
let nativeCommands = [native];
|
||||
let enabled = true;
|
||||
const root = {
|
||||
update(frame) {
|
||||
frame.commandList.push(...nativeCommands);
|
||||
},
|
||||
};
|
||||
const fromCache = Cesium.ShaderProgram.fromCache;
|
||||
const created = [];
|
||||
Cesium.ShaderProgram.fromCache = () => {
|
||||
const program = {
|
||||
allUniforms: {},
|
||||
destroyed: false,
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
},
|
||||
destroy() {
|
||||
assert.equal(this.destroyed, false);
|
||||
this.destroyed = true;
|
||||
},
|
||||
};
|
||||
created.push(program);
|
||||
return program;
|
||||
};
|
||||
const adapter = createCyberSonarGpu(
|
||||
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
|
||||
() => ({
|
||||
enabled,
|
||||
active: true,
|
||||
angle: 0,
|
||||
range: 100,
|
||||
opacity: 84,
|
||||
sector: 24,
|
||||
}),
|
||||
);
|
||||
const draw = () => {
|
||||
const frame = { commandList: [], passes: { render: true } };
|
||||
root.update(frame);
|
||||
return frame.commandList;
|
||||
};
|
||||
try {
|
||||
const first = draw()[0];
|
||||
assert.notEqual(first, native);
|
||||
first.dirty = false;
|
||||
assert.equal(draw()[0], first);
|
||||
assert.equal(first.dirty, false);
|
||||
assert.equal(created.length, 1, 'stable draws reuse the program');
|
||||
const buffer = { generation: 2 };
|
||||
native.vertexArray = buffer;
|
||||
native.shaderProgram = source();
|
||||
const swapped = draw()[0];
|
||||
assert.equal(swapped, first);
|
||||
assert.equal(swapped.vertexArray, buffer);
|
||||
assert.equal(swapped.pickId, 'native-pick');
|
||||
assert.equal(swapped.shaderProgram, created[1]);
|
||||
assert.equal(created[0].destroyed, true);
|
||||
assert.equal(adapter.stats.programs, 1);
|
||||
nativeCommands = [];
|
||||
draw();
|
||||
assert.equal(created[1].destroyed, true);
|
||||
assert.equal(
|
||||
adapter.stats.programs,
|
||||
0,
|
||||
'disabled layers retain no adapter program',
|
||||
);
|
||||
nativeCommands = [native];
|
||||
draw();
|
||||
enabled = false;
|
||||
assert.equal(draw()[0], native);
|
||||
assert.equal(
|
||||
created[2].destroyed,
|
||||
true,
|
||||
'theme/cockpit exit releases the derived program',
|
||||
);
|
||||
enabled = true;
|
||||
nativeCommands = [native, unknown];
|
||||
assert.deepEqual(
|
||||
draw(),
|
||||
nativeCommands,
|
||||
'a later failure rolls back earlier replacements in the same slice',
|
||||
);
|
||||
assert.equal(created[3].destroyed, true);
|
||||
assert.equal(adapter.stats.supported, false);
|
||||
assert.equal(adapter.stats.programs, 0);
|
||||
assert.equal(adapter.stats.commands, 0);
|
||||
assert.equal(adapter.stats.visibleCommands, 0);
|
||||
assert.deepEqual(draw(), nativeCommands);
|
||||
assert.equal(
|
||||
created.length,
|
||||
4,
|
||||
'unsupported draws do not retry compilation',
|
||||
);
|
||||
} finally {
|
||||
adapter.destroy();
|
||||
Cesium.ShaderProgram.fromCache = fromCache;
|
||||
points.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('disposal does not overwrite a successor update owner', () => {
|
||||
const root = { update() {} };
|
||||
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
|
||||
enabled: false,
|
||||
}));
|
||||
const successor = () => {};
|
||||
root.update = successor;
|
||||
adapter.destroy();
|
||||
assert.equal(root.update, successor);
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCyberSonarGpu } from './cyberSonarGpu.js';
|
||||
import {
|
||||
applyCyberSonarPrimitive,
|
||||
createCyberSonarSampler,
|
||||
isCyberContactThemeActive,
|
||||
isCyberContactSonarActive,
|
||||
isCyberSonarActive,
|
||||
readCyberSonarSettings,
|
||||
cyberSonarAngleDeg,
|
||||
syncCyberSonarAnimation,
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
} from './cyberSonar.js';
|
||||
|
||||
const alive = (value) => value && !value.isDestroyed?.();
|
||||
const diagnostics = new WeakMap();
|
||||
export const getCyberSonarSceneDiagnostics = (viewer) => ({
|
||||
...diagnostics.get(viewer),
|
||||
});
|
||||
|
||||
export function visitCyberSonarModels(primitive, visit, visible = true) {
|
||||
if (!alive(primitive)) return;
|
||||
visible = visible && primitive.show !== false;
|
||||
if (primitive instanceof Cesium.PrimitiveCollection) {
|
||||
for (let i = 0; i < primitive.length; i++)
|
||||
visitCyberSonarModels(primitive.get(i), visit, visible);
|
||||
} else if (primitive instanceof Cesium.Model) {
|
||||
visit(primitive, primitive, 'model', visible && primitive.ready);
|
||||
}
|
||||
}
|
||||
|
||||
/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain
|
||||
* their existing reversible CPU color treatment without walking native records.
|
||||
*/
|
||||
export function createCyberSonarScene(viewer, manager) {
|
||||
const scene = viewer?.scene;
|
||||
if (
|
||||
!scene?.postUpdate?.addEventListener ||
|
||||
!scene?.preRender?.addEventListener
|
||||
)
|
||||
return () => {};
|
||||
const tracked = new Map();
|
||||
const matrix = new Cesium.Matrix4();
|
||||
const position = new Cesium.Cartesian3();
|
||||
let epoch = 0,
|
||||
timer = null,
|
||||
disposed = false;
|
||||
let sampler, width, height, active, count;
|
||||
const stats = { contacts: 0, updateMs: 0, frames: 0 };
|
||||
const root = globalThis.document?.documentElement;
|
||||
const gpu = createCyberSonarGpu(scene, () => ({
|
||||
...readCyberSonarSettings(),
|
||||
enabled: isCyberContactThemeActive(),
|
||||
active: isCyberContactSonarActive(),
|
||||
angle: cyberSonarAngleDeg(performance.now()),
|
||||
}));
|
||||
stats.gpu = gpu.stats;
|
||||
let gpuSupported = gpu.stats.supported;
|
||||
diagnostics.set(viewer, stats);
|
||||
const restore = (visual, state) => {
|
||||
if (!alive(state.owner)) return;
|
||||
for (const key of state.keys)
|
||||
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
|
||||
};
|
||||
const visit = (visual, owner, kind, visible) => {
|
||||
let state = tracked.get(visual);
|
||||
if (!visible) {
|
||||
if (state) {
|
||||
restore(visual, state);
|
||||
tracked.delete(visual);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let p = visual.position;
|
||||
if (kind === 'model')
|
||||
p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position);
|
||||
else if (
|
||||
p &&
|
||||
owner.modelMatrix &&
|
||||
!Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY)
|
||||
)
|
||||
p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position);
|
||||
if (!p) return;
|
||||
const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15];
|
||||
const x =
|
||||
(matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w;
|
||||
const y =
|
||||
(matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w;
|
||||
if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return;
|
||||
if (!state) {
|
||||
state = {
|
||||
owner,
|
||||
keys:
|
||||
kind === 'label'
|
||||
? ['fillColor', 'outlineColor', 'backgroundColor']
|
||||
: kind === 'marker' && visual.outlineColor
|
||||
? ['color', 'outlineColor']
|
||||
: ['color'],
|
||||
epoch,
|
||||
};
|
||||
tracked.set(visual, state);
|
||||
}
|
||||
state.epoch = epoch;
|
||||
const factor = active
|
||||
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
|
||||
: sampler.neutral;
|
||||
for (const key of state.keys) {
|
||||
if (key === 'outlineColor' && visual.outlineWidth === 0) continue;
|
||||
if (key === 'backgroundColor' && !visual.showBackground) continue;
|
||||
applyCyberSonarPrimitive(
|
||||
visual,
|
||||
kind === 'label' ? sampler.label(factor) : factor,
|
||||
true,
|
||||
sampler,
|
||||
key,
|
||||
kind === 'model',
|
||||
);
|
||||
}
|
||||
count++;
|
||||
};
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (!isCyberContactThemeActive()) {
|
||||
for (const [visual, state] of tracked) restore(visual, state);
|
||||
tracked.clear();
|
||||
stats.contacts = 0;
|
||||
stats.updateMs = 0;
|
||||
return;
|
||||
}
|
||||
const started = performance.now();
|
||||
width = scene.canvas.clientWidth;
|
||||
height = scene.canvas.clientHeight;
|
||||
sampler = createCyberSonarSampler(width, height, started);
|
||||
active = isCyberContactSonarActive();
|
||||
Cesium.Matrix4.multiply(
|
||||
scene.camera.frustum.projectionMatrix,
|
||||
scene.camera.viewMatrix,
|
||||
matrix,
|
||||
);
|
||||
epoch++;
|
||||
count = 0;
|
||||
visitCyberSonarModels(scene.primitives, visit);
|
||||
for (const [visual, state] of tracked)
|
||||
if (state.epoch !== epoch) {
|
||||
restore(visual, state);
|
||||
tracked.delete(visual);
|
||||
}
|
||||
stats.contacts = count;
|
||||
stats.updateMs = performance.now() - started;
|
||||
stats.frames++;
|
||||
// No contacts, no scanner-owned render demand. In continuous scenes the
|
||||
// next rendered frame cancels this timer; parked scenes refresh at fleet
|
||||
// cadence. Idle update-only ticks must not continually postpone the timer.
|
||||
};
|
||||
let removeRender;
|
||||
// Aircraft fleet writers run in preRender, while tracked models and entity
|
||||
// visualizers update earlier. Compose AFTER all those owners and BEFORE
|
||||
// Cesium uploads primitive attributes for this draw. Requeue at postUpdate
|
||||
// so writers installed later (enable/re-enable/track) cannot overwrite sonar.
|
||||
// This only reorders one callback; the contact walk runs once per actual draw.
|
||||
const removeUpdate = scene.postUpdate.addEventListener(() => {
|
||||
if (isCyberSonarActive()) syncCyberSonarAnimation();
|
||||
const supportChanged = gpuSupported !== gpu.stats.supported;
|
||||
gpuSupported = gpu.stats.supported;
|
||||
const support = gpu.stats.supported ? 'supported' : 'unsupported';
|
||||
if (root?.dataset && root.dataset.cyberSonarGpu !== support)
|
||||
root.dataset.cyberSonarGpu = support;
|
||||
// Shader setup runs after preRender's model tint. If it fails while the
|
||||
// scene is parked (including Sonar OFF/reduced motion), draw once more so
|
||||
// the now-unsupported contact path restores models before becoming idle.
|
||||
if (supportChanged) scene.requestRender();
|
||||
const status = globalThis.document?.getElementById?.('cyber-sonar-status');
|
||||
const message = gpu.stats.supported
|
||||
? ''
|
||||
: 'Contact GPU unavailable. Native contacts remain visible.';
|
||||
if (status && status.textContent !== message) status.textContent = message;
|
||||
removeRender?.();
|
||||
removeRender = scene.preRender.addEventListener(update);
|
||||
});
|
||||
const removePostRender = scene.postRender?.addEventListener(() => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands))
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (!disposed && isCyberContactSonarActive()) scene.requestRender();
|
||||
}, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
});
|
||||
const unsubscribe = manager?.subscribe?.((change) => {
|
||||
if (change.type !== 'visibility') return;
|
||||
scene.requestRender();
|
||||
});
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
removeUpdate();
|
||||
removeRender?.();
|
||||
removePostRender?.();
|
||||
gpu.destroy();
|
||||
if (root?.dataset) delete root.dataset.cyberSonarGpu;
|
||||
unsubscribe?.();
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
for (const [visual, state] of tracked) restore(visual, state);
|
||||
tracked.clear();
|
||||
diagnostics.delete(viewer);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarIntensityAtScreenPoint,
|
||||
applyCyberSonarPrimitive,
|
||||
applyCyberSonarSettings,
|
||||
isCyberContactSonarActive,
|
||||
isCyberSonarActive,
|
||||
} from './cyberSonar.js';
|
||||
import {
|
||||
createCyberSonarScene,
|
||||
visitCyberSonarModels,
|
||||
getCyberSonarSceneDiagnostics,
|
||||
} from './cyberSonarScene.js';
|
||||
|
||||
test('frame sampler matches reference without per-contact settings access', () => {
|
||||
let reads = 0;
|
||||
const values = {
|
||||
cyberSonarOpacity: '62',
|
||||
cyberSonarSector: '40',
|
||||
cyberSonarRange: '110',
|
||||
};
|
||||
const root = {
|
||||
dataset: new Proxy(values, {
|
||||
get(o, k) {
|
||||
reads++;
|
||||
return o[k];
|
||||
},
|
||||
}),
|
||||
};
|
||||
const frame = createCyberSonarSampler(1000, 800, 1234, root),
|
||||
once = reads;
|
||||
for (let x = 0; x < 1000; x += 33)
|
||||
for (let y = 0; y < 800; y += 31) frame.at(x, y);
|
||||
assert.equal(reads, once);
|
||||
for (let x = 0; x < 1000; x += 33)
|
||||
for (let y = 0; y < 800; y += 31)
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
frame.at(x, y) -
|
||||
cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, {
|
||||
dataset: values,
|
||||
}),
|
||||
) < 1e-10,
|
||||
);
|
||||
});
|
||||
|
||||
test('model walk never enumerates native contact or EntityCluster records', () => {
|
||||
const root = new Cesium.PrimitiveCollection();
|
||||
const points = root.add(new Cesium.PointPrimitiveCollection());
|
||||
points.add();
|
||||
points.get = () => {
|
||||
throw new Error('must not enumerate contacts');
|
||||
};
|
||||
const cluster = root.add(new Cesium.EntityCluster());
|
||||
Object.defineProperty(cluster, '_billboardCollection', {
|
||||
get() {
|
||||
throw new Error('must not access cluster internals');
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const seen = [];
|
||||
visitCyberSonarModels(root, (...v) => seen.push(v));
|
||||
assert.equal(seen.length, 0);
|
||||
delete cluster._billboardCollection;
|
||||
root.destroy();
|
||||
});
|
||||
|
||||
test('models retain render-pass alpha and owner refresh survives teardown', () => {
|
||||
const frame = createCyberSonarSampler(1000, 800, 0);
|
||||
const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) };
|
||||
applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true);
|
||||
assert.equal(model.color.alpha, 1);
|
||||
assert.ok(model.color.red < 1);
|
||||
const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7);
|
||||
model.color = owner;
|
||||
applyCyberSonarPrimitive(model, 1, false);
|
||||
assert.equal(model.color, owner);
|
||||
const unset = {};
|
||||
applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true);
|
||||
applyCyberSonarPrimitive(unset, 1, false);
|
||||
assert.equal(unset.color, undefined);
|
||||
});
|
||||
|
||||
test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => {
|
||||
const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } };
|
||||
const options = {
|
||||
root,
|
||||
body: { classList: { contains: () => false } },
|
||||
hud: { classList: { contains: () => true } },
|
||||
reducedMotion: false,
|
||||
};
|
||||
root.dataset.cyberSonarMode = 'css';
|
||||
assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined);
|
||||
assert.equal(root.dataset.cyberSonarMode, undefined);
|
||||
assert.equal(isCyberSonarActive(options), true);
|
||||
assert.equal(isCyberContactSonarActive(options), true);
|
||||
assert.equal(
|
||||
applyCyberSonarSettings({ mode: 'invalid' }, root).mode,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(isCyberContactSonarActive(options), true);
|
||||
root.dataset.cyberSonarGpu = 'unsupported';
|
||||
assert.equal(isCyberContactSonarActive(options), false);
|
||||
});
|
||||
|
||||
test('native owner colors remain untouched and scene teardown releases listeners', () => {
|
||||
const previous = globalThis.document;
|
||||
const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } };
|
||||
let cockpit = false;
|
||||
globalThis.document = {
|
||||
documentElement: root,
|
||||
body: { classList: { contains: () => cockpit } },
|
||||
getElementById: () => ({ classList: { contains: () => true } }),
|
||||
};
|
||||
const primitives = new Cesium.PrimitiveCollection();
|
||||
const points = primitives.add(new Cesium.PointPrimitiveCollection());
|
||||
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
|
||||
const original = primitives.update;
|
||||
const scene = {
|
||||
primitives,
|
||||
postUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
canvas: { clientWidth: 1000, clientHeight: 800 },
|
||||
camera: {
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
},
|
||||
requestRender() {},
|
||||
};
|
||||
const viewer = { scene };
|
||||
const dispose = createCyberSonarScene(viewer);
|
||||
try {
|
||||
for (const enabled of ['on', 'off', 'on']) {
|
||||
root.dataset.cyberSonar = enabled;
|
||||
for (const opacity of [35, 84, 100]) {
|
||||
root.dataset.cyberSonarOpacity = String(opacity);
|
||||
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
|
||||
scene.postUpdate.raiseEvent();
|
||||
scene.preRender.raiseEvent();
|
||||
assert.equal(point.color.alpha, 0.7);
|
||||
assert.equal(point.color.red, 0.6);
|
||||
}
|
||||
}
|
||||
const previousTimeout = globalThis.setTimeout;
|
||||
const previousClear = globalThis.clearTimeout;
|
||||
let timers = 0;
|
||||
globalThis.setTimeout = () => {
|
||||
timers++;
|
||||
return 1;
|
||||
};
|
||||
globalThis.clearTimeout = () => {};
|
||||
try {
|
||||
const gpu = getCyberSonarSceneDiagnostics(viewer).gpu;
|
||||
gpu.commands = 6;
|
||||
gpu.visibleCommands = 0;
|
||||
scene.postRender.raiseEvent();
|
||||
assert.equal(timers, 0, 'culled batches must not schedule a sonar draw');
|
||||
gpu.visibleCommands = 1;
|
||||
scene.postRender.raiseEvent();
|
||||
assert.equal(timers, 1, 'a visible batch keeps the sweep moving');
|
||||
root.dataset.cyberSonar = 'off';
|
||||
scene.postRender.raiseEvent();
|
||||
assert.equal(timers, 1, 'OFF does not schedule contact draws');
|
||||
} finally {
|
||||
globalThis.setTimeout = previousTimeout;
|
||||
globalThis.clearTimeout = previousClear;
|
||||
}
|
||||
cockpit = true;
|
||||
scene.postUpdate.raiseEvent();
|
||||
scene.preRender.raiseEvent();
|
||||
root.dataset.uiTheme = 'tactical';
|
||||
scene.postUpdate.raiseEvent();
|
||||
scene.preRender.raiseEvent();
|
||||
dispose();
|
||||
dispose();
|
||||
assert.equal(primitives.update, original);
|
||||
assert.equal(scene.postUpdate.numberOfListeners, 0);
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(scene.postRender.numberOfListeners, 0);
|
||||
assert.equal(root.dataset.cyberSonarGpu, undefined);
|
||||
} finally {
|
||||
dispose();
|
||||
primitives.destroy();
|
||||
globalThis.document = previous;
|
||||
}
|
||||
});
|
||||
|
||||
for (const reducedMotion of [false, true]) {
|
||||
test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => {
|
||||
const previousDocument = globalThis.document;
|
||||
const previousMatchMedia = globalThis.matchMedia;
|
||||
const previousTimeout = globalThis.setTimeout;
|
||||
const previousClear = globalThis.clearTimeout;
|
||||
const root = {
|
||||
dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' },
|
||||
};
|
||||
const status = { textContent: '' };
|
||||
globalThis.document = {
|
||||
documentElement: root,
|
||||
body: { classList: { contains: () => false } },
|
||||
getElementById: (id) =>
|
||||
id === 'cyber-sonar-status'
|
||||
? status
|
||||
: { classList: { contains: () => true } },
|
||||
};
|
||||
globalThis.matchMedia = () => ({ matches: reducedMotion });
|
||||
let scheduled = 0;
|
||||
globalThis.setTimeout = () => {
|
||||
scheduled++;
|
||||
return 1;
|
||||
};
|
||||
globalThis.clearTimeout = () => {};
|
||||
// A model-shaped fixture exercises the actual scene walk without loading a
|
||||
// glTF or allocating a WebGL context. Rendering remains the browser gate.
|
||||
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
|
||||
const model = Object.create(Cesium.Model.prototype, {
|
||||
ready: { value: true },
|
||||
show: { value: true },
|
||||
color: { value: nativeColor.clone(), writable: true },
|
||||
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
|
||||
});
|
||||
const primitives = new Cesium.PrimitiveCollection({
|
||||
destroyPrimitives: false,
|
||||
});
|
||||
primitives.add(model);
|
||||
const points = new Cesium.PointPrimitiveCollection();
|
||||
const nativeCommand = {
|
||||
owner: points,
|
||||
uniformMap: {},
|
||||
shaderProgram: {
|
||||
vertexShaderSource: new Cesium.ShaderSource({
|
||||
sources: ['void main() {}'],
|
||||
}),
|
||||
},
|
||||
};
|
||||
primitives.update = (frame) => frame.commandList.push(nativeCommand);
|
||||
let requests = 0;
|
||||
const scene = {
|
||||
primitives,
|
||||
postUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
canvas: { clientWidth: 1000, clientHeight: 800 },
|
||||
drawingBufferWidth: 1000,
|
||||
drawingBufferHeight: 800,
|
||||
camera: {
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
},
|
||||
requestRender() {
|
||||
requests++;
|
||||
},
|
||||
};
|
||||
const viewer = { scene };
|
||||
const dispose = createCyberSonarScene(viewer);
|
||||
try {
|
||||
scene.postUpdate.raiseEvent();
|
||||
scene.preRender.raiseEvent();
|
||||
assert.notDeepEqual(
|
||||
model.color,
|
||||
nativeColor,
|
||||
'Cyber neutral treatment precedes shader compilation',
|
||||
);
|
||||
const frame = { commandList: [], passes: { render: true } };
|
||||
primitives.update(frame);
|
||||
assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false);
|
||||
assert.equal(frame.commandList[0], nativeCommand);
|
||||
scene.postRender.raiseEvent();
|
||||
assert.equal(
|
||||
scheduled,
|
||||
0,
|
||||
'inactive sweep has no timer to perform recovery',
|
||||
);
|
||||
scene.postUpdate.raiseEvent();
|
||||
assert.equal(root.dataset.cyberSonarGpu, 'unsupported');
|
||||
assert.match(status.textContent, /Native contacts remain visible/);
|
||||
assert.equal(
|
||||
requests,
|
||||
1,
|
||||
'support loss explicitly wakes the parked scene',
|
||||
);
|
||||
scene.preRender.raiseEvent();
|
||||
assert.deepEqual(
|
||||
model.color,
|
||||
nativeColor,
|
||||
'the requested draw restores native model color',
|
||||
);
|
||||
assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0);
|
||||
scene.postRender.raiseEvent();
|
||||
for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent();
|
||||
assert.equal(
|
||||
requests,
|
||||
1,
|
||||
'unsupported idle ticks do not create a render loop',
|
||||
);
|
||||
assert.equal(scheduled, 0);
|
||||
dispose();
|
||||
scene.postUpdate.raiseEvent();
|
||||
assert.equal(requests, 1, 'disposed callbacks cannot wake the scene');
|
||||
} finally {
|
||||
dispose();
|
||||
points.destroy();
|
||||
primitives.destroy();
|
||||
globalThis.document = previousDocument;
|
||||
globalThis.matchMedia = previousMatchMedia;
|
||||
globalThis.setTimeout = previousTimeout;
|
||||
globalThis.clearTimeout = previousClear;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -438,6 +438,20 @@ export function classifyAircraft({ typeCode, category } = {}) {
|
||||
return 'airliner';
|
||||
}
|
||||
|
||||
/** Readable name of each class, for cards and readouts. */
|
||||
export const AIRCRAFT_CLASS_LABELS = Object.freeze({
|
||||
light: 'Light aircraft',
|
||||
glider: 'Glider',
|
||||
turboprop: 'Turboprop',
|
||||
airliner: 'Airliner',
|
||||
widebody: 'Widebody',
|
||||
quadjet: 'Four-engine jet',
|
||||
helicopter: 'Helicopter',
|
||||
fastjet: 'Fast jet',
|
||||
bizjet: 'Business jet',
|
||||
uav: 'Drone',
|
||||
});
|
||||
|
||||
/** Billboard scale multipliers (skylight GLYPH_SCALE, + fastjet). */
|
||||
export const CLASS_SCALE_2D = {
|
||||
light: 0.62,
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*
|
||||
* Providers (injected — keeps the engine pure and node-testable):
|
||||
* getRecords(layerKey) → Array<record> (layer accessor snapshot)
|
||||
* resolveRegionRing(name) → Promise<{ring, name}|null> (NE pack / admin boundary)
|
||||
* resolveRegionRing(name) → Promise<{ring, name}|{error:'region-timeout'}|null>
|
||||
* getViewContext() → {lat, lon, viewRadiusKm, bounds?} (camera-derived)
|
||||
*
|
||||
* @module data/analystEngine
|
||||
@@ -77,6 +77,11 @@ export const ANALYST_LAYERS = {
|
||||
text: ['name', 'operator', 'river', 'output'],
|
||||
flags: [],
|
||||
},
|
||||
'fire-perimeters': {
|
||||
numeric: ['acres', 'containedPct', 'personnel', 'costToDate'],
|
||||
text: ['name', 'state', 'county', 'cause', 'behavior', 'complexity'],
|
||||
flags: [],
|
||||
},
|
||||
};
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
@@ -229,6 +234,14 @@ export function createAnalystEngine(providers) {
|
||||
const scope = spec.scope || { kind: 'view' };
|
||||
if (scope.kind === 'region' && scope.name) {
|
||||
const region = await providers.resolveRegionRing(scope.name);
|
||||
if (region?.error === 'region-timeout') {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'region-timeout',
|
||||
error: `Looking up the boundary for "${scope.name}" is taking too long — ask again in a moment.`,
|
||||
coverage: { layersQueried, scope: `region:${scope.name}:timeout` },
|
||||
};
|
||||
}
|
||||
if (!region?.ring) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -187,6 +187,19 @@ test('analyst: unresolved region is an honest failure, not empty success', async
|
||||
assert.match(r.error, /Atlantis/);
|
||||
});
|
||||
|
||||
test('analyst: a region lookup timeout is reported as region-timeout', async () => {
|
||||
const eng = createAnalystEngine({
|
||||
getRecords: () => FLIGHTS,
|
||||
resolveRegionRing: async (name) => ({ name, ring: null, error: 'region-timeout' }),
|
||||
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
|
||||
});
|
||||
const r = await eng.query({ layers: ['flights'], scope: { kind: 'region', name: 'Texas' } });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.code, 'region-timeout');
|
||||
assert.match(r.error, /Texas/);
|
||||
assert.equal(r.coverage.scope, 'region:Texas:timeout');
|
||||
});
|
||||
|
||||
test('analyst: route fields queryable from cached enrichment only', async () => {
|
||||
const r = await makeEngine().query({
|
||||
layers: ['flights'], scope: { kind: 'anywhere' },
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file Load a JSON file shipped beside the source modules.
|
||||
*
|
||||
* The browser fetches the file from `url` — a `new URL('./….json',
|
||||
* import.meta.url)` written in the calling module, which Vite serves as-is in
|
||||
* dev and emits as an asset in the production build. Under Node (node:test)
|
||||
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
|
||||
* JSON-attributed import runs instead.
|
||||
*
|
||||
* The attributed import cannot serve the browser: the Vite dev server answers
|
||||
* it with a JavaScript module, which the browser rejects for a JSON import.
|
||||
*
|
||||
* @module data/bundledJson
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
|
||||
* @param {() => Promise<{default: any}>} importJson - The caller's literal
|
||||
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
|
||||
* @returns {Promise<any>} The parsed JSON.
|
||||
*/
|
||||
export async function loadBundledJson(url, importJson) {
|
||||
if (url.protocol === 'file:') return (await importJson()).default;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
|
||||
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
|
||||
const importMarine = () =>
|
||||
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
|
||||
|
||||
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch');
|
||||
const pack = await loadBundledJson(MARINE, importMarine);
|
||||
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
|
||||
assert.equal(fetchMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
|
||||
const requests = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requests.push(String(url));
|
||||
return new Response('{"features":[]}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
|
||||
const importJson = () => assert.fail('the browser path must not import JSON');
|
||||
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
|
||||
assert.deepEqual(requests, [url.href]);
|
||||
});
|
||||
|
||||
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
|
||||
await assert.rejects(
|
||||
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
|
||||
/HTTP 404/,
|
||||
);
|
||||
});
|
||||
@@ -86,6 +86,14 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://gibs.earthdata.nasa.gov" target="_blank" rel="noopener">gibs.earthdata.nasa.gov</a> · ' +
|
||||
'<a href="https://lpdaac.usgs.gov/products/hlss30v002/" target="_blank" rel="noopener">HLS product page</a>',
|
||||
},
|
||||
{
|
||||
key: 'wfigs',
|
||||
html:
|
||||
'Wildfire perimeters: ' +
|
||||
'<a href="https://data-nifc.opendata.arcgis.com/" target="_blank" rel="noopener">National Interagency Fire Center (WFIGS)</a>' +
|
||||
' · Incident information: ' +
|
||||
'<a href="https://inciweb.wildfire.gov/" target="_blank" rel="noopener">InciWeb</a>',
|
||||
},
|
||||
{
|
||||
key: 'overpass',
|
||||
html:
|
||||
|
||||
+63
-4
@@ -48,6 +48,12 @@ import {
|
||||
viewScaleForAltitude,
|
||||
} from './detectionPolicy.js';
|
||||
import { detectionBracketOpacity } from './detectionPresentation.js';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
cyberSonarFrameDelay,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
isCyberContactThemeActive as isCyberMapThemeActive,
|
||||
} from '../cyberSonar.js';
|
||||
|
||||
/**
|
||||
* @module detection
|
||||
@@ -206,6 +212,8 @@ let _lastRenderMs = 0;
|
||||
let _lastPaintMs = 0;
|
||||
let _lastSolveMs = 0;
|
||||
let _throttleSkipCount = 0;
|
||||
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
let _cyberSonarRenderTimer = null;
|
||||
/** @type {Function|null} External callback invoked when the detection mode changes */
|
||||
let _onModeChange = null;
|
||||
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
|
||||
@@ -325,6 +333,7 @@ export function initDetection(viewer, layers, onModeChange) {
|
||||
|
||||
/** Release the host lane and all retained detection runtime state. */
|
||||
export function destroyDetection() {
|
||||
_clearCyberSonarRenderTimer();
|
||||
if (_cockpitModeListener && typeof window !== 'undefined') {
|
||||
window.removeEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
@@ -362,6 +371,25 @@ export function destroyDetection() {
|
||||
_lastPaintMs = 0;
|
||||
_lastSolveMs = 0;
|
||||
_throttleSkipCount = 0;
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
function _clearCyberSonarRenderTimer() {
|
||||
if (_cyberSonarRenderTimer != null) {
|
||||
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
|
||||
_cyberSonarRenderTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _scheduleCyberSonarRender(nowMs) {
|
||||
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
|
||||
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
||||
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
|
||||
_cyberSonarRenderTimer = null;
|
||||
if (isCyberSonarActive()) {
|
||||
governorRequestRender('cyber-sonar-cadence');
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -682,7 +710,18 @@ function _applyModeState() {
|
||||
function _shouldPaintDetectionLane(frame) {
|
||||
if (_mode === MODE_OFF || _suspended) return false;
|
||||
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
|
||||
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
_frameCount++;
|
||||
if (isCyberSonarActive()) {
|
||||
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
|
||||
if (!layoutChanged && sonarDelay > 0) {
|
||||
_scheduleCyberSonarRender(nowMs);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
_clearCyberSonarRenderTimer();
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
// The skip and the follow-up request come from ONE decision, so the valve
|
||||
// cannot drop a frame without handing its request forward — see
|
||||
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
|
||||
@@ -729,6 +768,14 @@ function _paintDetectionLane(frame) {
|
||||
_lastRenderMs = performance.now() - start;
|
||||
_lastSolveMs = result.solveMs || 0;
|
||||
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
|
||||
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
|
||||
_lastCyberSonarPaintAt = nowMs;
|
||||
_scheduleCyberSonarRender(nowMs);
|
||||
} else {
|
||||
_clearCyberSonarRenderTimer();
|
||||
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
if (_lastDiagnostics) {
|
||||
_lastDiagnostics.frameTotalMs = _lastRenderMs;
|
||||
_lastDiagnostics.paintMs = _lastPaintMs;
|
||||
@@ -748,7 +795,7 @@ function _paintDetectionLane(frame) {
|
||||
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
|
||||
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
|
||||
// 220 ms, and the settled alpha never painted).
|
||||
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
|
||||
nowMs,
|
||||
enabledAtMs: _enableTime,
|
||||
fadeMs: FADE_MS,
|
||||
animatingLabelCount: result.animatingCount || 0,
|
||||
@@ -996,7 +1043,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
|
||||
placement.centerY,
|
||||
keyhole,
|
||||
);
|
||||
const alpha = acquireFade * temporalAlpha * radialAlpha;
|
||||
const alpha =
|
||||
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
|
||||
if (alpha <= 0.001) return;
|
||||
|
||||
let row = _calloutPool[_calloutCount];
|
||||
@@ -1146,6 +1194,7 @@ function _materializeCandidate(
|
||||
keyholeAlpha,
|
||||
placements,
|
||||
color: obj._candidateColor,
|
||||
sonarFactor: obj._candidateSonarFactor ?? 1,
|
||||
// Costed here rather than in the object sweep: the sweep walks every
|
||||
// observation in view, while this runs only for callouts that actually
|
||||
// placed — a budgeted handful per frame.
|
||||
@@ -1180,6 +1229,9 @@ function _drawOverlay(frame) {
|
||||
// with every arbiter call in this function — so the frame that paints an
|
||||
// animation's final state is the same frame that ends its demand.
|
||||
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
|
||||
const sonarActive = isCyberSonarActive();
|
||||
const cyberMapActive = isCyberMapThemeActive();
|
||||
const sonarSampler = createCyberSonarSampler(width, height, now);
|
||||
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
|
||||
const shouldSolve =
|
||||
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
|
||||
@@ -1362,11 +1414,16 @@ function _drawOverlay(frame) {
|
||||
// The bracket follows the same linear radial keyhole fade as its callout.
|
||||
const color = colorFor(resolveTier(obj));
|
||||
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
|
||||
const bracketAlpha = detectionBracketAlpha(
|
||||
const sonarFactor =
|
||||
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
|
||||
const sonarLabelFactor = sonarSampler.label(sonarFactor);
|
||||
const admissionAlpha = detectionBracketAlpha(
|
||||
obj.type,
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity,
|
||||
cyberMapActive,
|
||||
);
|
||||
const bracketAlpha = admissionAlpha * sonarFactor;
|
||||
if (bracketAlpha > 0) {
|
||||
const transit = obj.tier?.startsWith('transit_');
|
||||
(transit ? appendTransitBracket : appendCornerBracket)(
|
||||
@@ -1404,13 +1461,15 @@ function _drawOverlay(frame) {
|
||||
|
||||
obj._cohortSourceId = sourceId;
|
||||
obj._cohortPriority = _semanticPriority(obj);
|
||||
obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8);
|
||||
obj._cohortBand =
|
||||
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
|
||||
obj._cohortHash = stableIdentityHash(layerId, sourceId);
|
||||
obj._candidateScreenX = sx;
|
||||
obj._candidateScreenY = sy;
|
||||
obj._candidateHalfW = halfW;
|
||||
obj._candidateHalfH = halfH;
|
||||
obj._candidateColor = color;
|
||||
obj._candidateSonarFactor = sonarLabelFactor;
|
||||
obj._candidatePrimary = primary;
|
||||
obj._candidateMicro = micro;
|
||||
|
||||
|
||||
@@ -92,14 +92,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
|
||||
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
|
||||
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
|
||||
* the default, so a caller that does not know it reproduces the shipped look.
|
||||
* @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR
|
||||
* readability floor; the bracket follows the radial keyhole alpha exactly.
|
||||
* @returns {number} Paint alpha, 0-1.
|
||||
*/
|
||||
export function detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
|
||||
strictKeyholeFade = false,
|
||||
) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
|
||||
if (strictKeyholeFade) return alpha;
|
||||
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
|
||||
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent'
|
||||
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
|
||||
});
|
||||
|
||||
test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => {
|
||||
assert.equal(
|
||||
detectionBracketAlpha(
|
||||
'AIR',
|
||||
0.01,
|
||||
KEYHOLE_OUTSIDE_OPACITY_DEFAULT,
|
||||
true,
|
||||
),
|
||||
0.01,
|
||||
);
|
||||
assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42);
|
||||
assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1);
|
||||
});
|
||||
|
||||
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
|
||||
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
|
||||
// default ever moves, this fails rather than silently shifting the shipped
|
||||
|
||||
@@ -285,7 +285,15 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
|
||||
assert.ok(paintLane, 'detection.js still has a paint lane');
|
||||
assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/,
|
||||
'demand must not re-sample the clock — that is the dropped terminal frame');
|
||||
assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/);
|
||||
assert.match(
|
||||
paintLane,
|
||||
/const nowMs = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/,
|
||||
);
|
||||
assert.match(
|
||||
paintLane,
|
||||
/detectionNeedsFollowUpFrame\(\{[\s\S]*?\n\s*nowMs,/,
|
||||
'the sonar cadence and fade demand share the same frame-clock sample',
|
||||
);
|
||||
assert.match(paintLane, /animatingLabelCount: result\.animatingCount/,
|
||||
'demand counts fades in both directions');
|
||||
// …and `animatingCount` must really be the BOTH-directions count. Feeding the
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import { detectionBracketAlpha } from './detectionPolicy.js';
|
||||
|
||||
// Exercise the actual renderer's admission and paint expressions. A sweep is
|
||||
// appearance only: it must not reshuffle candidates at the cohort cutoff.
|
||||
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
|
||||
const alphaBlock = source.match(
|
||||
/const admissionAlpha = detectionBracketAlpha\([\s\S]*?const bracketAlpha = [^;]+;/,
|
||||
)?.[0];
|
||||
const bandAssignment = source.match(/obj\._cohortBand\s*=\s*[^;]+;/)?.[0];
|
||||
|
||||
test('Sonar changes paint alpha without changing detection cohort admission', () => {
|
||||
assert.ok(alphaBlock, 'renderer separates admission from paint');
|
||||
assert.ok(bandAssignment);
|
||||
for (const cyberMapActive of [false, true]) {
|
||||
for (const type of ['AIR', 'SAT', 'SEA']) {
|
||||
for (const keyholeAlpha of [0, 0.01, 0.125, 0.5, 0.999, 1]) {
|
||||
const alpha = detectionBracketAlpha(
|
||||
type,
|
||||
keyholeAlpha,
|
||||
0.35,
|
||||
cyberMapActive,
|
||||
);
|
||||
const expectedBand = alpha >= 0.999 ? 8 : Math.floor(alpha * 8);
|
||||
for (const sonarFactor of [0.01, 0.12, 0.5, 1]) {
|
||||
const state = {
|
||||
obj: { type },
|
||||
keyholeAlpha,
|
||||
keyholeOutsideOpacity: 0.35,
|
||||
cyberMapActive,
|
||||
sonarFactor,
|
||||
detectionBracketAlpha,
|
||||
};
|
||||
vm.runInNewContext(
|
||||
alphaBlock + bandAssignment + '; result = bracketAlpha;',
|
||||
state,
|
||||
);
|
||||
assert.equal(state.obj._cohortBand, expectedBand);
|
||||
assert.equal(state.result, alpha * sonarFactor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { firePerimetersProxy } from '../../server/providers/firePerimeters.js';
|
||||
|
||||
const incidentHtml = `<meta property="og:updated_time" content="2026-09-23T11:32:31-04:00" />
|
||||
<th>Date of Origin</th>
|
||||
<td class="text-tabular text-left"><time datetime="2026-07-15T20:20:00Z">Origin</time></td>`;
|
||||
const incidentTimes = {
|
||||
createdMs: Date.parse('2026-07-15T20:20:00Z'),
|
||||
changedMs: Date.parse('2026-09-23T11:32:31-04:00'),
|
||||
};
|
||||
const incidentRedirect = () =>
|
||||
new Response('redirect', {
|
||||
status: 301,
|
||||
headers: { location: 'http://inciweb.wildfire.gov/test-fire' },
|
||||
});
|
||||
|
||||
const feature = (id = 'fire') => ({
|
||||
properties: { attr_UniqueFireIdentifier: id, poly_DateCurrent: 123 },
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-108, 35],
|
||||
[-107, 35],
|
||||
[-107, 36],
|
||||
[-108, 35],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
function install(options = {}, hook = 'configureServer') {
|
||||
let handler;
|
||||
const plugin = firePerimetersProxy(options);
|
||||
assert.equal(typeof plugin.configureServer, 'function');
|
||||
assert.equal(typeof plugin.configurePreviewServer, 'function');
|
||||
plugin[hook]({
|
||||
middlewares: {
|
||||
use(path, callback) {
|
||||
assert.equal(path, '/api/fire-perimeters');
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
return async (url = '/', 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;
|
||||
};
|
||||
}
|
||||
|
||||
for (const hook of ['configureServer', 'configurePreviewServer']) {
|
||||
test(`${hook}: paging stops at five pages and normalizes the combined snapshot`, async () => {
|
||||
const calls = [];
|
||||
const request = install(
|
||||
{
|
||||
now: () => 1234,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push(new URL(url));
|
||||
assert.ok(options.signal instanceof AbortSignal);
|
||||
assert.equal(options.redirect, 'error');
|
||||
return Response.json({
|
||||
features: [feature(String(calls.length))],
|
||||
exceededTransferLimit: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
hook,
|
||||
);
|
||||
const res = await request('/?url=https://invalid.example&resultOffset=999');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.fetchedAt, 1234);
|
||||
assert.equal(res.body.rows.length, 5);
|
||||
assert.deepEqual(
|
||||
calls.map((url) => url.searchParams.get('resultOffset')),
|
||||
[null, '1', '2', '3', '4'],
|
||||
);
|
||||
assert.ok(calls.every((url) => url.hostname === 'services3.arcgis.com'));
|
||||
assert.equal(calls[0].searchParams.get('maxAllowableOffset'), '0.001');
|
||||
});
|
||||
}
|
||||
|
||||
test('an explicit transfer-limit false stops paging; a fresh cache does not fetch', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json({
|
||||
features: [feature()],
|
||||
exceededTransferLimit: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
const first = await request();
|
||||
assert.deepEqual((await request()).body, first.body);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('a failed refresh serves the last good perimeter snapshot as stale', async () => {
|
||||
let clock = 0,
|
||||
calls = 0;
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
if (++calls > 1) throw new Error('private upstream details');
|
||||
return Response.json({ features: [feature()] });
|
||||
},
|
||||
});
|
||||
await request();
|
||||
clock = 300_000;
|
||||
const res = await request();
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.stale, true);
|
||||
assert.equal(res.body.fetchedAt, 0);
|
||||
assert.equal(res.body.rows[0].stableId, 'fire');
|
||||
});
|
||||
|
||||
test('oversized streamed and declared bodies produce sanitized 502 responses', async () => {
|
||||
for (const declared of [true, false]) {
|
||||
let cancelled = false;
|
||||
const request = install({
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(1024 * 1024));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
{
|
||||
headers: declared
|
||||
? { 'Content-Length': String(17 * 1024 * 1024) }
|
||||
: {},
|
||||
},
|
||||
),
|
||||
});
|
||||
const res = await request();
|
||||
assert.equal(res.status, 502);
|
||||
assert.deepEqual(res.body, { error: 'fire_perimeters_unavailable' });
|
||||
assert.equal(cancelled, true);
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed WFIGS data does not become a cached empty snapshot', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json({ error: 'upstream detail' });
|
||||
},
|
||||
});
|
||||
assert.equal((await request()).status, 502);
|
||||
assert.equal((await request()).status, 502);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('concurrent requests share one upstream operation for every route', async () => {
|
||||
for (const [path, payload] of [
|
||||
['/', { features: [] }],
|
||||
['/inciweb/index', []],
|
||||
['/inciweb/publication/42', {}],
|
||||
]) {
|
||||
let release,
|
||||
calls = 0;
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
await pending;
|
||||
if (path.includes('/publication/'))
|
||||
return calls === 1 ? incidentRedirect() : new Response(incidentHtml);
|
||||
return Response.json(payload);
|
||||
},
|
||||
});
|
||||
const responses = [request(path), request(path)];
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(calls, 1);
|
||||
release();
|
||||
for (const res of await Promise.all(responses))
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls, path.includes('/publication/') ? 2 : 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('bad publication ids and non-GET requests never fetch upstream', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new Error('unexpected');
|
||||
},
|
||||
});
|
||||
for (const id of ['', 'abc', '-1', '1234567890', '1/2', '%31', '1.0'])
|
||||
assert.equal((await request(`/inciweb/publication/${id}`)).status, 400);
|
||||
for (const path of ['/', '/inciweb/index', '/inciweb/publication/123']) {
|
||||
for (const method of ['POST', 'HEAD', 'PUT', 'DELETE'])
|
||||
assert.equal((await request(path, method)).status, 405);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test('InciWeb index preserves the fixed POST, caches for one hour and serves stale arrays', async () => {
|
||||
let clock = 0,
|
||||
calls = 0;
|
||||
const rows = [{ incident_id: '42', incident_title: 'Fire' }];
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async (url, options) => {
|
||||
calls++;
|
||||
assert.equal(url, 'https://inciweb.wildfire.gov/api/single-publication/');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.deepEqual(options.headers, { 'Content-Type': 'application/json' });
|
||||
assert.deepEqual(JSON.parse(options.body), { title: '' });
|
||||
if (calls > 1) throw new Error('offline');
|
||||
return Response.json(rows);
|
||||
},
|
||||
});
|
||||
assert.deepEqual((await request('/inciweb/index')).body, rows);
|
||||
clock = 3_599_999;
|
||||
assert.deepEqual((await request('/inciweb/index')).body, rows);
|
||||
assert.equal(calls, 1);
|
||||
clock++;
|
||||
const stale = await request('/inciweb/index');
|
||||
assert.deepEqual(stale.body, rows);
|
||||
assert.equal(stale.headers['X-Data-Stale'], 'true');
|
||||
});
|
||||
|
||||
test('publication timestamps, thirty-minute TTL and oldest-entry eviction', async () => {
|
||||
let clock = 0;
|
||||
const calls = [];
|
||||
const request = install({
|
||||
now: () => clock,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
return url.includes('/node/')
|
||||
? incidentRedirect()
|
||||
: new Response(incidentHtml);
|
||||
},
|
||||
});
|
||||
const get = (id) =>
|
||||
request(`/inciweb/publication/${id}`, 'GET', `peer-${id}`);
|
||||
assert.deepEqual((await get(1)).body, incidentTimes);
|
||||
clock = 1_799_999;
|
||||
await get(1);
|
||||
assert.equal(calls.length, 2);
|
||||
clock++;
|
||||
await get(1);
|
||||
assert.equal(calls.length, 4);
|
||||
for (let id = 2; id <= 257; id++) assert.equal((await get(id)).status, 200);
|
||||
await get(2);
|
||||
assert.equal(calls.length, 516);
|
||||
await get(1);
|
||||
assert.equal(calls.length, 518);
|
||||
assert.equal(calls.at(-2), 'https://inciweb.wildfire.gov/node/1');
|
||||
});
|
||||
|
||||
test('InciWeb index and publication reads enforce their smaller caps', async () => {
|
||||
for (const [path, cap] of [
|
||||
['/inciweb/index', 4 * 1024 * 1024],
|
||||
['/inciweb/publication/1', 2 * 1024 * 1024],
|
||||
]) {
|
||||
const request = install({
|
||||
fetchImpl: async () =>
|
||||
new Response('{}', { headers: { 'Content-Length': String(cap + 1) } }),
|
||||
});
|
||||
assert.equal((await request(path)).status, 502);
|
||||
}
|
||||
});
|
||||
|
||||
test('the per-client limit stops a loop while another client can read the cache', async () => {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return Response.json({ features: [] });
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 60; i++) assert.equal((await request()).status, 200);
|
||||
assert.equal((await request()).status, 429);
|
||||
assert.equal((await request('/', 'GET', 'another-client')).status, 200);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('publication redirects share a signal, cancel the redirect body and force HTTPS', async () => {
|
||||
for (const status of [301, 302, 303, 307, 308]) {
|
||||
const calls = [];
|
||||
const redirect = new Response('redirect', {
|
||||
status,
|
||||
headers: {
|
||||
location: 'http://inciweb.wildfire.gov/incident/test-fire#details',
|
||||
},
|
||||
});
|
||||
const request = install({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return calls.length === 1 ? redirect : new Response(incidentHtml);
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await request('/inciweb/publication/42')).body,
|
||||
incidentTimes,
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(redirect.bodyUsed, true);
|
||||
assert.equal(calls[0].url, 'https://inciweb.wildfire.gov/node/42');
|
||||
assert.equal(calls[0].options.redirect, 'manual');
|
||||
assert.equal(
|
||||
calls[1].url,
|
||||
'https://inciweb.wildfire.gov/incident/test-fire',
|
||||
);
|
||||
assert.equal(calls[1].options.redirect, 'error');
|
||||
assert.ok(calls[0].options.signal instanceof AbortSignal);
|
||||
assert.equal(calls[0].options.signal, calls[1].options.signal);
|
||||
}
|
||||
});
|
||||
|
||||
test('unsafe publication redirect locations fail before a second fetch', async () => {
|
||||
for (const location of [
|
||||
'https://evil.example/x',
|
||||
'/a/../x',
|
||||
'/x?q=1',
|
||||
'/x?',
|
||||
'/%2e%2e/x',
|
||||
'/a/b/c',
|
||||
'',
|
||||
]) {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return new Response('redirect', { status: 301, headers: { location } });
|
||||
},
|
||||
});
|
||||
assert.equal((await request('/inciweb/publication/42')).status, 502);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('incident pages without valid timestamps fail closed and are not cached', async () => {
|
||||
for (const html of [
|
||||
'<html>No markers</html>',
|
||||
incidentHtml.replaceAll(/2026-[^"]+/g, 'invalid'),
|
||||
]) {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return calls % 2 ? incidentRedirect() : new Response(html);
|
||||
},
|
||||
});
|
||||
assert.equal((await request('/inciweb/publication/42')).status, 502);
|
||||
assert.equal((await request('/inciweb/publication/42')).status, 502);
|
||||
assert.equal(calls, 4);
|
||||
}
|
||||
});
|
||||
|
||||
test('direct 200 incident pages work, including one missing timestamp', async () => {
|
||||
for (const [html, expected] of [
|
||||
[incidentHtml, incidentTimes],
|
||||
[
|
||||
incidentHtml.replace('og:updated_time', 'unrelated'),
|
||||
{ ...incidentTimes, changedMs: null },
|
||||
],
|
||||
[
|
||||
incidentHtml.replace('Date of Origin', 'Unrelated'),
|
||||
{ ...incidentTimes, createdMs: null },
|
||||
],
|
||||
]) {
|
||||
let calls = 0;
|
||||
const request = install({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return new Response(html);
|
||||
},
|
||||
});
|
||||
assert.deepEqual((await request('/inciweb/publication/42')).body, expected);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('publication HTML streams are capped at 2 MiB', async () => {
|
||||
let cancelled = false;
|
||||
const request = install({
|
||||
fetchImpl: async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(1024 * 1024));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
assert.equal((await request('/inciweb/publication/42')).status, 502);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
@@ -5,9 +5,15 @@
|
||||
* VIIRS header (confirmed live 2026-07-16):
|
||||
* latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,
|
||||
* instrument,confidence,version,bright_ti5,frp,daynight
|
||||
* MODIS header:
|
||||
* latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,
|
||||
* instrument,confidence,version,bright_t31,frp,daynight
|
||||
*
|
||||
* Quirks this module owns:
|
||||
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) — passed through raw.
|
||||
* - `confidence` is CATEGORICAL for VIIRS (`l`/`n`/`h`) and numeric 0-100 for
|
||||
* MODIS — passed through raw.
|
||||
* - Brightness maps from `bright_ti4`/`brightness`; the secondary band maps
|
||||
* from `bright_ti5`/`bright_t31`.
|
||||
* - `acq_time` is NOT zero-padded ("45" = 00:45 UTC) — kept as-is in records;
|
||||
* {@link acquisitionMsUtc} does the padding.
|
||||
* - Upstream errors come back as HTML or plain text ("Invalid MAP_KEY"),
|
||||
|
||||
@@ -48,6 +48,25 @@ test('fixture first row maps every field, categorical confidence preserved raw',
|
||||
assert.equal(first.instrument, 'VIIRS');
|
||||
});
|
||||
|
||||
test('MODIS schema maps fields and preserves numeric confidence raw', () => {
|
||||
const header =
|
||||
'latitude,longitude,brightness,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_t31,frp,daynight';
|
||||
const row =
|
||||
'34.123,-117.456,321.5,1.2,0.8,2026-07-16,1045,Aqua,MODIS,78,6.1NRT,290.25,12.4,D';
|
||||
const [record] = parseFirmsCsv(`${header}\n${row}\n`);
|
||||
assert.equal(record.lat, 34.123);
|
||||
assert.equal(record.lon, -117.456);
|
||||
assert.equal(record.frp, 12.4);
|
||||
assert.equal(record.brightness, 321.5);
|
||||
assert.equal(record.brightnessTi5, 290.25);
|
||||
assert.equal(record.daynight, 'D');
|
||||
assert.equal(record.acqDate, '2026-07-16');
|
||||
assert.equal(record.acqTime, '1045');
|
||||
assert.equal(record.satellite, 'Aqua');
|
||||
assert.equal(record.instrument, 'MODIS');
|
||||
assert.equal(record.confidence, '78');
|
||||
});
|
||||
|
||||
test('acquisitionMsUtc: "1006" = 10:06 UTC', () => {
|
||||
assert.equal(
|
||||
acquisitionMsUtc('2026-07-16', '1006'),
|
||||
|
||||
@@ -9,7 +9,7 @@ assert.notEqual(start, -1, 'FIRMS refresh function exists');
|
||||
const end = config.indexOf('\n }', start);
|
||||
assert.notEqual(end, -1, 'FIRMS refresh function closes');
|
||||
const refreshSource = config.slice(start, end + 4);
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT'];
|
||||
const SOURCES = ['VIIRS_NOAA20_NRT', 'VIIRS_NOAA21_NRT', 'VIIRS_SNPP_NRT', 'MODIS_NRT'];
|
||||
const NOW = Date.UTC(2026, 8, 11, 12);
|
||||
const recent = { acqDate: '2026-09-11', acqTime: '1100' };
|
||||
|
||||
@@ -42,7 +42,7 @@ test('FIRMS retains large sources in order and filters expired rows', async () =
|
||||
assert.equal(result.fires[199_999], large.at(-1));
|
||||
assert.equal(result.fires.at(-1), last);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: [200_000, 1, 0][index], ok: true,
|
||||
source, count: [200_000, 1, 0, 0][index], ok: true,
|
||||
})));
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ test('FIRMS keeps successful sources when another upstream fails', async () => {
|
||||
if (source === SOURCES[1]) throw new Error('upstream unavailable');
|
||||
return [recent];
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 2);
|
||||
assert.equal(result.fires.length, 3);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 1 ? 0 : 1, ok: index !== 1,
|
||||
})));
|
||||
@@ -65,7 +65,7 @@ test('FIRMS reports one failure if consuming a source throws before append', asy
|
||||
// Fault injection for aggregation; ordinary parsed CSV returns an array.
|
||||
return { length: 1, [Symbol.iterator]() { throw new Error('aggregation failed'); } };
|
||||
})('fixture');
|
||||
assert.equal(result.fires.length, 2);
|
||||
assert.equal(result.fires.length, 3);
|
||||
assert.deepEqual(result.sources, SOURCES.map((source, index) => ({
|
||||
source, count: index === 0 ? 0 : 1, ok: index !== 0,
|
||||
})));
|
||||
|
||||
@@ -7,3 +7,16 @@
|
||||
(22,980 bytes). Used by offline decode/source tests and the explicit `qa-traffic --fixtures`
|
||||
browser mode — it is a point-in-time congestion snapshot, not a bundled
|
||||
data layer, and is never loaded by ordinary application startup. © TomTom.
|
||||
- `adsb-austin-frames.txt` — 112 raw Mode S frames (`*<hex>;`, one per line,
|
||||
in receive order) received over Austin, TX, and
|
||||
`adsb-austin-dump1090-aircraft.json` — dump1090-fa's `aircraft.json` decode
|
||||
of the same frames, trimmed to the fields the tests assert on. Used by the
|
||||
Local ADS-B decoder and record-adapter tests; never loaded at runtime.
|
||||
- `adsb-austin-capture-timed.json` — a second ~90 s receive over Austin, TX:
|
||||
846 extended squitters from six aircraft (`frames`: `[ms, hex]`), with
|
||||
dump1090-fa's decoded positions and emitter categories for the same
|
||||
interval (`dump1090`: per ICAO `flight`, `category` and `fixes` as
|
||||
`[ms, lat, lon]`). The capture has no per-frame timestamps; each frame's
|
||||
time is interpolated between the frames whose decoded position equals a
|
||||
dump1090 fix, timed as dump1090's `now − seen_pos`. Used by the decoder's
|
||||
category, speed-check and track-accuracy tests; never loaded at runtime.
|
||||
@@ -0,0 +1 @@
|
||||
{"receiver":{"latitude":30.27,"longitude":-97.8},"frames":[[10,"8d0d0c07501d0030aa4c0c2140c6"],[80,"8da5b3a758b99408f8dda679bb00"],[150,"8da0b702e1150d0000000004eb56"],[220,"8d0d0c0722594377e37820b79a0d"],[290,"8d0d0c07990c111d483c0c92e5f3"],[360,"8da0b702590d40253c4af26e47d9"],[430,"8da0b70299105d833818018770a8"],[500,"8d0d0c07501bf7da9cd71ab9e124"],[570,"8da5b3a7ea466864013c085e68e2"],[640,"8da3627d58a580665464f23579f2"],[780,"8d0d0c07990c111d483c0c92e5f3"],[850,"8da0b702590d47cf46d60b223463"],[920,"8da0b70299105d83380c015f9ea8"],[990,"8d0d0c07f8200002004ab89cec0b"],[1060,"8da0b702f8122006005ab8b0ce08"],[1130,"8d0d0c07501bf030d84c0b00e5d5"],[1200,"8da5b3a758b9905ff8528e447e65"],[1275,"8d0d0c07990c111d483c0c92e5f3"],[1350,"8da0b702590d5025384afb963e58"],[1425,"8da0b70299105d83300481465660"],[1500,"8da3627d58a5840ef0ef82e7f39f"],[1600,"8d0d0c07501bf030f64c0af8367b"],[1675,"8da5b3a7990da30b30085d4a3fb6"],[1750,"8d0d0c07990c111d483c0b6d35d7"],[1825,"8da5b3a723041333c31c2045cc84"],[1900,"8da0b702590d4025364affe4f100"],[1963,"8da0b70299105c833008010a006e"],[2088,"8dabe7c59915ad0d380456abc760"],[2150,"8dabe7c558af87ce8ccac01d62c7"],[2213,"8d0d0c07501be031124c090e5e08"],[2275,"8d0d0c07e1058a00000000c4d44e"],[2338,"8da3627de106290000000030ef86"],[2400,"8da5b3a758b994091cdd51bae36d"],[2460,"8d0d0c07990c111d483c0c92e5f3"],[2580,"8da0b702590d47cf40d6170e6567"],[2640,"8da0b70299105c83100c01774a47"],[2700,"8d0d0c07501be031244c08456bbe"],[2800,"8da5b3a7990da30b30085d4a3fb6"],[2900,"8da27e81592147de34ccbfe1e71f"],[3000,"8d0d0c07990c111d683c0b2849fe"],[3100,"8da0b702590d57cf3cd61d759697"],[3133,"8da0b70299105c83101081d8ce87"],[3200,"8da5b3a7ea466864013c085e68e2"],[3233,"8d0d0c07501be7db16d7163df6ea"],[3267,"8da3627d991417bb100854ccc1a2"],[3300,"8da27e81592147de3cccc18db35e"],[3333,"8da27e819910390f18080b8ece70"],[3367,"8d0d0c07990c111d683c0cd799da"],[3400,"8da5b3a758b990601e52360713ad"],[3450,"8da0b702f8122006005ab8b0ce08"],[3500,"8d0d0c07f8200002004ab89cec0b"],[3550,"8da0b702590d50252e4b0d6c0eab"],[3600,"8da0b70299105b83101481e9cced"],[3650,"8da5b3a7f82300060049b8e08700"],[3700,"8d0d0c07501be7db32d715b033d5"],[3700,"8da3627d58a580652c64ebca78d9"],[3860,"8da5b3a7990da30b30085eb5d7a4"],[3940,"8d0d0c07990c111d68380b107ffe"],[4020,"8dabe7c59915ad0d380456abc760"],[4100,"8da0b702590d57cf38d625439697"],[4140,"8da0b70299105b83101801a6902d"],[4180,"8d0d0c07501bd031724c05906709"],[4220,"8dabe7c558af87ceb8ca66b4e8a7"],[4260,"8d0d0c07990c111d68380cefafda"],[4300,"8da5b3a758b9906030520dfe8d1c"],[4417,"8d0d0c07501bd031844c05445a49"],[4533,"8da0b702590d57cf36d62b019958"],[4650,"8da0b70299105b83101881a196ed"],[4767,"8da5b3a7990da30b30085d4a3fb6"],[4883,"8d0d0c07990c111d88380b35ff28"],[5000,"8da0b702590d57cf34d62ee5fde5"],[5000,"8da0b70299105b82f01481e7b168"],[5000,"8da0b702e1150d0000000004eb56"],[5000,"8d0d0c07501bd7db74d71347e30c"],[5150,"8da0b70227c74d5462082004597a"],[5300,"8da5b3a758b990604051e7fb7390"],[5333,"8d0d0c0722594377e37820b79a0d"],[5367,"8d0d0c07990c111d88380cca2f0c"],[5400,"8dabe7c558af8024c23ee7c8e962"],[5460,"8dabe7c5f8230006004ab8db4fc2"],[5520,"8da0b702590d5025264b1f025060"],[5580,"8da0b70299105b82f01401e0b7a8"],[5640,"8da5b3a7ea466864013c085e68e2"],[5700,"8d0d0c07501bd031b44c03dc465d"],[5760,"8da5b3a7990da30b30085eb5d7a4"],[5820,"8da0b702f8122006005ab8b0ce08"],[5880,"8d0d0c07990c111d88380b35ff28"],[5940,"8dabe7c59915ad0d300456c56568"],[6000,"8da0b702590d57cf30d6372dc85c"],[6050,"8da0b70299105b82f01401e0b7a8"],[6100,"8d0d0c07501bc031c64c021a765d"],[6250,"8d0d0c07f8200002004ab89cec0b"],[6400,"8dabe7c558af8024d83eba9c7813"],[6500,"8d0d0c07501bc7dbb4d71117af08"],[6600,"8d0d0c07990c111da83c0b48b501"],[6700,"8da5b3a7990da30b30085d4a3fb6"],[6800,"8dabe7c59915ad0d300456c56568"],[6900,"8d0d0c07501bc7dbc8d710a36866"],[7050,"8d0d0c07990c111da83c0cb76525"],[7200,"8da5b3a758b990606451947414a7"],[7300,"8dabe7c558af87cef8c9e42b1cae"],[7360,"8dabe7c5ea428867215c08fe0874"],[7420,"8d0d0c07e1058a00000000c4d44e"],[7540,"8d0d0c07501bc0320a4c005f6240"],[7600,"8d0d0c07990c111da83c0b48b501"],[7660,"8da0b702590d67cf28d645cd6577"],[7720,"8da0b70299105a82f01081dc8da6"],[7780,"8da5b3a7990da30b30085eb5d7a4"],[7840,"8dabe7c59915ad0d300456c56568"],[7900,"8d0d0c07501bb032264bff008fc5"],[7933,"8d0d0c07990c111da83c0cb76525"],[7967,"8da5b3a7ea466864013c085e68e2"],[8000,"8da0b702590d67cf26d6487082aa"],[8150,"8da0b70299105a82d01481a1c78f"],[8300,"8da5b3a758b9906076516b48fcb5"],[8300,"8dabe7c558af8025003e6a60fadf"],[8364,"8da5b3a7f82300060049b8e08700"],[8427,"8da0b702f8122006005ab8b0ce08"],[8491,"8d0d0c07f8200002004ab89cec0b"],[8555,"8d0d0c07990c111dc83c0b87317a"],[8618,"8d0d0c07501bb032444bfe2b5735"],[8682,"8da5b3a7e10d2800000000aefd57"],[8745,"8da0b702590d67cf24d64d94e617"],[8809,"8da0b70299105a82d01481a1c78f"],[8873,"8dabe7c59915ad0d300456c56568"],[8936,"8d0d0c07990c111dc83c0c78e15e"],[9000,"8d0d0c07501bb032564bfeedbba7"],[9200,"8da0b702590d6025164b3fca5cc9"],[9267,"8da0b70299105a82d010019ef74f"],[9333,"8da5b3a758b9940996dc34d66e25"],[9400,"8dabe7c55Line truncated
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"now": 1790127097.5,
|
||||
"messages": 112,
|
||||
"aircraft": [
|
||||
{
|
||||
"hex": "a15c54",
|
||||
"flight": "SKW3301 ",
|
||||
"alt_baro": 5050,
|
||||
"gs": 287.5,
|
||||
"track": 291.2,
|
||||
"baro_rate": 3072,
|
||||
"seen": 31.5,
|
||||
"messages": 14,
|
||||
"rssi": -28.2
|
||||
},
|
||||
{
|
||||
"hex": "ae5d8a",
|
||||
"flight": "SHINR42 ",
|
||||
"lat": 30.269662,
|
||||
"lon": -97.793262,
|
||||
"alt_baro": 1600,
|
||||
"gs": 129.9,
|
||||
"track": 330.5,
|
||||
"baro_rate": 64,
|
||||
"seen": 30.5,
|
||||
"seen_pos": 34.6,
|
||||
"messages": 74,
|
||||
"rssi": -30.0
|
||||
},
|
||||
{
|
||||
"hex": "a3c775",
|
||||
"lat": 30.360625,
|
||||
"lon": -97.919659,
|
||||
"alt_baro": 34000,
|
||||
"gs": 434.4,
|
||||
"track": 283.7,
|
||||
"baro_rate": 0,
|
||||
"seen": 38.2,
|
||||
"seen_pos": 43.4,
|
||||
"messages": 20,
|
||||
"rssi": -30.5
|
||||
},
|
||||
{
|
||||
"hex": "a46e6a",
|
||||
"gs": 319.8,
|
||||
"track": 351.2,
|
||||
"baro_rate": 2688,
|
||||
"seen": 32.0,
|
||||
"messages": 4,
|
||||
"rssi": -37.1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
*8da46e6a99143227b0ac21405d5c;
|
||||
*8da46e6aea428858015c080bb810;
|
||||
*5da46e6ae86d20;
|
||||
*8da3c775990da70d180457361e57;
|
||||
*8dae5d8aea00085ee53e004546c3;
|
||||
*8dae5d8a580d77d658d63e32c82f;
|
||||
*5dae5d8a54a93d;
|
||||
*8da3c77558af803cce430dad950f;
|
||||
*200015b8f3156e;
|
||||
*02e615b82df0c7;
|
||||
*02e615b82df0c7;
|
||||
*8da3c775990da70d180458c9be1f;
|
||||
*8dae5d8a99a4400e70088202ff80;
|
||||
*5dae5d8a54a93d;
|
||||
*200015b8f3156e;
|
||||
*200015b8f3156e;
|
||||
*8dae5d8a580d702c844b24b6523e;
|
||||
*8dae5d8af80300020049bc1cd8f3;
|
||||
*200015b8f3156e;
|
||||
*20000197d80069;
|
||||
*5dae5d8a54a93d;
|
||||
*020001970f9c90;
|
||||
*8dae5d8a580d77d688d63170d088;
|
||||
*8da3c77558af87e6a0cdf2c8bbf8;
|
||||
*8dae5d8aea00085ee53e004546c3;
|
||||
*8dae5d8a274c824e4b4ca06f9608;
|
||||
*5dae5d8a54a93d;
|
||||
*8dae5d8a99a4400e700c823ac980;
|
||||
*020001970f9c90;
|
||||
*5da15c54111a4b;
|
||||
*8dae5d8a580d77d6a0d62aa4760f;
|
||||
*5da3c7758f35d4;
|
||||
*8dae5d8aea00085ee53e004546c3;
|
||||
*8dae5d8a99a4410e500c827cbf67;
|
||||
*8da15c54581b8019e24c96ebec6d;
|
||||
*8dae5d8a580d77d6ccd61fccec2f;
|
||||
*5dae5d8a54a93d;
|
||||
*8dae5d8a99a4410e500c827cbf67;
|
||||
*8dae5d8a580d77d6d8d61cd91129;
|
||||
*8dae5d8a99a4410e500c827cbf67;
|
||||
*8dae5d8a580d77d6e2d619cbefa5;
|
||||
*8dae5d8af80300020049bc1cd8f3;
|
||||
*20000197d80069;
|
||||
*80000197580d77d6e8d6172cc0e3;
|
||||
*8dae5d8a580d702d044b0189a64f;
|
||||
*020001970f9c90;
|
||||
*8dae5d8a99a4410e500882448967;
|
||||
*8dae5d8a580d77d6fad6128791c3;
|
||||
*5dae5d8a54a93d;
|
||||
*8dae5d8a99a4410e500882448967;
|
||||
*020001970f9c90;
|
||||
*8da15c54581bc01a424c2f038351;
|
||||
*8dae5d8ae1080800000000e6168f;
|
||||
*8da3c77558af87e730ccd3243e2f;
|
||||
*8dae5d8a580d87d728d6051c99a8;
|
||||
*5dae5d8a54a93d;
|
||||
*8dae5d8aea00085ee53e004546c3;
|
||||
*8dae5d8a99a4410e500882448967;
|
||||
*8dae5d8a580d802d584aea9aed46;
|
||||
*5da15c54111a4b;
|
||||
*8dae5d8a99a4410e500c827cbf67;
|
||||
*2000019827a021;
|
||||
*8dae5d8af80300020049bc1cd8f3;
|
||||
*8dae5d8a580d87d754d5f94050dd;
|
||||
*8dae5d8a99a4410e500c827cbf67;
|
||||
*5dae5d8a54a93d;
|
||||
*8dae5d8a99a4410e500c827cbf67;
|
||||
*02000198f03cd8;
|
||||
*8dae5d8a580d87d76ed5f252fa10;
|
||||
*8dae5d8aea00085ee53e004546c3;
|
||||
*8da15c5499150a0df0ac0d713d8d;
|
||||
*200015b8f3156e;
|
||||
*8da3c775990da70d100458a71c17;
|
||||
*8dae5d8a580d87d778d5ef5c1b0d;
|
||||
*8dae5d8a99a4410e500882448967;
|
||||
*5dae5d8a54a93d;
|
||||
*02818393027eb0;
|
||||
*5da3c7758f35d5;
|
||||
*5da3c7758f35d5;
|
||||
*5da3c7758f35d4;
|
||||
*200015b8f3156e;
|
||||
*8dae5d8a580d87d782d5ec2e3d53;
|
||||
*8da15c54ea178858015e88a53d08;
|
||||
*5dae5d8a54a93d;
|
||||
*8dae5d8a99a4410e500882448967;
|
||||
*02000198f03cd8;
|
||||
*8dae5d8ae1080800000000e6168f;
|
||||
*200015b8f3156e;
|
||||
*8dae5d8a580d802db04ad2d09e9c;
|
||||
*02e195b8f5cd24;
|
||||
*8da15c5499150b0db0b40d687b11;
|
||||
*8dae5d8aea00085ee53e004546c3;
|
||||
*8dae5d8a99a4410e500882448967;
|
||||
*8dae5d8a580d802dc64accee13e8;
|
||||
*2000019827a021;
|
||||
*8dae5d8a99a4410e500881bb6175;
|
||||
*8dae5d8a580d802ddc4ac5b9d917;
|
||||
*8dae5d8af80300020049bc1cd8f3;
|
||||
*80000198580d87d7cad5d9283036;
|
||||
*02000198f03cd8;
|
||||
*5da15c54111a4b;
|
||||
*8dae5d8a580d87d7ecd5d0a28b94;
|
||||
*8dae5d8a99a4410e500881bb6175;
|
||||
*8da15c54234cb5f3cf0c6055934a;
|
||||
*5dae5d8a54a93d;
|
||||
*8da15c5499150d0d50c40e1a396a;
|
||||
*8da15c5499150d0d30c40ed5bd11;
|
||||
*5da15c54111a4b;
|
||||
*5da46e6ae86d29;
|
||||
*8da15c54581f201b484af27089b0;
|
||||
*02000198f03cd8;
|
||||
*5dae5d8a54a93d;
|
||||
@@ -480,6 +480,11 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
||||
}),
|
||||
Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }),
|
||||
Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }),
|
||||
Object.freeze({
|
||||
id: 'fire-perimeters',
|
||||
token: '2',
|
||||
disposition: 'enabled-only',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'flights',
|
||||
token: 'f',
|
||||
|
||||
@@ -194,8 +194,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, 27);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 27);
|
||||
assert.equal(REGISTERED_LAYER_IDS.length, 28);
|
||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
|
||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||
assert.throws(
|
||||
@@ -2418,3 +2418,10 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
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, ['fire-perimeters', 'recent-imagery', 'wind']);
|
||||
assert.equal(LAYER_STATE_REGISTRY.find(({ id }) => id === 'fire-perimeters').token, '2');
|
||||
assert.deepEqual(decodeLayerStateParams(new URLSearchParams(encode(decoded))), decoded);
|
||||
});
|
||||
@@ -16,6 +16,8 @@ const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([
|
||||
'enabled-only',
|
||||
'enabled+options',
|
||||
'enabled+mirrored-options',
|
||||
// Registered but never serialized (for example hardware-local layers).
|
||||
'local-only',
|
||||
]);
|
||||
|
||||
function isAbortError(error) {
|
||||
@@ -2302,6 +2304,14 @@ export class LayerLifecycle {
|
||||
return () => this._activityListeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask presentation to repaint layer rows now, for data that lands outside
|
||||
* the manager tick (Transit's proximity polls, Directions' route steps).
|
||||
*/
|
||||
refreshLayerStats() {
|
||||
this._publishActivity({ type: 'status' });
|
||||
}
|
||||
|
||||
_publishActivity(change) {
|
||||
for (const callback of this._activityListeners) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
LOCAL_RECEIVERS_ROUTE,
|
||||
createLocalReceiversHandler,
|
||||
localReceiversProxy,
|
||||
parseLocalReceiverFeeds,
|
||||
} from '../../server/providers/local-receivers.js';
|
||||
import { localProviderPlugins } from '../../server/providers/local.js';
|
||||
|
||||
const FIXTURE = readFileSync(
|
||||
new URL('./fixtures/adsb-austin-dump1090-aircraft.json', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const FIXTURE_NOW_MS = JSON.parse(FIXTURE).now * 1000;
|
||||
const FEED_1090 = 'http://localhost:8080/data/aircraft.json';
|
||||
const FEED_978 = 'http://127.0.0.1:8978/data/aircraft.json';
|
||||
|
||||
function quietLogger() {
|
||||
const lines = [];
|
||||
return {
|
||||
lines,
|
||||
warn: (line) => lines.push(line),
|
||||
info: (line) => lines.push(line),
|
||||
};
|
||||
}
|
||||
|
||||
function respond(status = 200, body = FIXTURE, headers = {}) {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
async function call(handler, method = 'GET') {
|
||||
const res = {
|
||||
writeHead(status, headers) {
|
||||
Object.assign(this, { status, headers });
|
||||
},
|
||||
end(body) {
|
||||
this.body = body;
|
||||
},
|
||||
};
|
||||
await handler.handle({ url: '/', method }, res);
|
||||
return { ...res, json: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
test('LOCAL_RECEIVER_FEEDS accepts local http(s) aircraft.json feeds per band', () => {
|
||||
const { configured, feeds } = parseLocalReceiverFeeds(
|
||||
` 1090=${FEED_1090} , 978=http://skyaware.local/skyaware978/data/aircraft.json,1090=https://192.168.1.20:8443/tar1090/data/aircraft.json`,
|
||||
);
|
||||
assert.equal(configured, true);
|
||||
assert.deepEqual(
|
||||
feeds.map(({ band, label, url }) => ({ band, label, url })),
|
||||
[
|
||||
{ band: '1090', label: '1090 MHz #1', url: FEED_1090 },
|
||||
{
|
||||
band: '978',
|
||||
label: '978 MHz UAT',
|
||||
url: 'http://skyaware.local:80/skyaware978/data/aircraft.json',
|
||||
},
|
||||
{
|
||||
band: '1090',
|
||||
label: '1090 MHz #2',
|
||||
url: 'https://192.168.1.20:8443/tar1090/data/aircraft.json',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.deepEqual(parseLocalReceiverFeeds(undefined), {
|
||||
configured: false,
|
||||
feeds: [],
|
||||
});
|
||||
assert.equal(parseLocalReceiverFeeds(' , ').configured, false);
|
||||
});
|
||||
|
||||
test('LOCAL_RECEIVER_FEEDS rejects public, link-local and named hosts, bad paths and bands', () => {
|
||||
const cases = {
|
||||
'1090=http://8.8.8.8/data/aircraft.json': 'host must be',
|
||||
'1090=http://169.254.169.254/latest/aircraft.json': 'host must be',
|
||||
'1090=http://example.com/data/aircraft.json': 'host must be',
|
||||
'1090=http://[::1]:8080/data/aircraft.json': 'host must be',
|
||||
'1090=http://localhost:8080/data/receiver.json': 'path must end',
|
||||
'1090=http://localhost:8080/data/aircraft.json.bak': 'path must end',
|
||||
'1090=http://localhost:8080/data/aircraft.json?x=1': 'query',
|
||||
'1090=http://user:pw@localhost:8080/data/aircraft.json': 'credentials',
|
||||
'1090=ftp://localhost/data/aircraft.json': 'scheme',
|
||||
'1090=not a url': 'not a valid URL',
|
||||
'868=http://localhost:8080/data/aircraft.json': 'band must be',
|
||||
'http://localhost:8080/data/aircraft.json': 'expected band=url',
|
||||
};
|
||||
for (const [entry, reason] of Object.entries(cases)) {
|
||||
const [feed] = parseLocalReceiverFeeds(entry).feeds;
|
||||
assert.equal(feed.url, undefined, `${entry} must not be fetchable`);
|
||||
assert.match(feed.reason, new RegExp(reason), entry);
|
||||
}
|
||||
const [badBand] = parseLocalReceiverFeeds(
|
||||
'868=http://localhost/aircraft.json',
|
||||
).feeds;
|
||||
assert.equal(badBand.band, null);
|
||||
assert.equal(badBand.label, 'entry 1');
|
||||
});
|
||||
|
||||
test('an unconfigured route answers 200 without fetching anything', async (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('must not fetch');
|
||||
});
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: undefined,
|
||||
logger: quietLogger(),
|
||||
});
|
||||
const res = await call(handler);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.json, { configured: false, feeds: [], records: [] });
|
||||
});
|
||||
|
||||
test('construction mounts the route once and neither parses nor fetches', (t) => {
|
||||
t.mock.method(globalThis, 'fetch', () => {
|
||||
throw Error('construction must not fetch');
|
||||
});
|
||||
const plugins = localProviderPlugins();
|
||||
assert.equal(
|
||||
plugins.filter((plugin) => plugin.name === 'local-receivers-proxy').length,
|
||||
1,
|
||||
);
|
||||
const routes = [];
|
||||
localReceiversProxy({
|
||||
feedsValue: '',
|
||||
logger: quietLogger(),
|
||||
}).configureServer({ middlewares: { use: (route) => routes.push(route) } });
|
||||
assert.deepEqual(routes, [LOCAL_RECEIVERS_ROUTE]);
|
||||
});
|
||||
|
||||
test('live feeds are read in parallel and labelled with their band', async () => {
|
||||
const calls = [];
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: `1090=${FEED_1090},978=${FEED_978}`,
|
||||
now: () => FIXTURE_NOW_MS + 2_000,
|
||||
logger: quietLogger(),
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, redirect: options.redirect });
|
||||
return respond();
|
||||
},
|
||||
});
|
||||
const { status, json } = await call(handler);
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(
|
||||
calls.sort((a, b) => (a.url < b.url ? 1 : -1)),
|
||||
[
|
||||
{ url: FEED_1090, redirect: 'manual' },
|
||||
{ url: FEED_978, redirect: 'manual' },
|
||||
],
|
||||
);
|
||||
assert.equal(json.configured, true);
|
||||
assert.deepEqual(json.feeds, [
|
||||
{
|
||||
band: '1090',
|
||||
label: '1090 MHz',
|
||||
status: 'live',
|
||||
aircraft: 4,
|
||||
ageMs: 2_000,
|
||||
},
|
||||
{
|
||||
band: '978',
|
||||
label: '978 MHz UAT',
|
||||
status: 'live',
|
||||
aircraft: 4,
|
||||
ageMs: 2_000,
|
||||
},
|
||||
]);
|
||||
assert.equal(json.records.length, 8);
|
||||
const shinr = json.records.filter((record) => record.icao === 'ae5d8a');
|
||||
assert.deepEqual(
|
||||
shinr.map(({ band, source }) => ({ band, source })),
|
||||
[
|
||||
{ band: '1090', source: 'feed' },
|
||||
{ band: '978', source: 'feed' },
|
||||
],
|
||||
);
|
||||
// Ages are anchored at the feed's own `now`.
|
||||
assert.equal(shinr[0].lastPositionAt, FIXTURE_NOW_MS - 34_600);
|
||||
assert.equal(shinr[0].lat, 30.269662);
|
||||
});
|
||||
|
||||
test('a feed whose own now is over 10 s old is stale; failures are unreachable', async () => {
|
||||
const logger = quietLogger();
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: [
|
||||
`1090=${FEED_1090}`,
|
||||
`978=${FEED_978}`,
|
||||
'1090=http://10.0.0.5/redirect/aircraft.json',
|
||||
'1090=http://10.0.0.6/big/aircraft.json',
|
||||
'1090=http://10.0.0.7/error/aircraft.json',
|
||||
'978=http://10.0.0.8/garbage/aircraft.json',
|
||||
'1090=http://8.8.8.8/aircraft.json',
|
||||
].join(','),
|
||||
now: () => FIXTURE_NOW_MS + 10_001,
|
||||
logger,
|
||||
maxBytes: 4_096,
|
||||
fetchImpl: async (url) => {
|
||||
if (url === FEED_978) throw new TypeError('connect ECONNREFUSED secret');
|
||||
if (url.includes('/redirect/'))
|
||||
return respond(302, '', { location: 'http://169.254.169.254/' });
|
||||
if (url.includes('/big/'))
|
||||
return respond(200, `{"aircraft":[${' '.repeat(5_000)}]}`);
|
||||
if (url.includes('/error/'))
|
||||
return respond(500, 'upstream internal detail');
|
||||
if (url.includes('/garbage/')) return respond(200, '<html>');
|
||||
if (url.includes('8.8.8.8')) throw Error('invalid feed was fetched');
|
||||
return respond();
|
||||
},
|
||||
});
|
||||
const { json, body } = await call(handler);
|
||||
assert.deepEqual(
|
||||
json.feeds.map(({ band, status }) => `${band} ${status}`),
|
||||
[
|
||||
'1090 stale',
|
||||
'978 unreachable',
|
||||
'1090 unreachable',
|
||||
'1090 unreachable',
|
||||
'1090 unreachable',
|
||||
'978 unreachable',
|
||||
'1090 invalid',
|
||||
],
|
||||
);
|
||||
assert.equal(json.feeds[0].ageMs, 10_001);
|
||||
assert.equal(json.records.length, 4, 'stale feed records still flow');
|
||||
for (const secret of ['ECONNREFUSED', 'secret', 'internal detail', '169.254'])
|
||||
assert.equal(body.includes(secret), false, `${secret} leaked`);
|
||||
assert.equal(
|
||||
body.includes('10.0.0'),
|
||||
false,
|
||||
'feed addresses stay server-side',
|
||||
);
|
||||
assert.ok(
|
||||
logger.lines.some((line) =>
|
||||
/LOCAL_RECEIVER_FEEDS feed-7 .* rejected: host must be/.test(line),
|
||||
),
|
||||
);
|
||||
assert.ok(logger.lines.some((line) => /REDIRECT_REFUSED/.test(line)));
|
||||
assert.ok(logger.lines.some((line) => /RESPONSE_TOO_LARGE/.test(line)));
|
||||
});
|
||||
|
||||
test('a feed that exceeds the timeout is unreachable', async () => {
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: `1090=${FEED_1090}`,
|
||||
logger: quietLogger(),
|
||||
timeoutMs: 5,
|
||||
fetchImpl: (url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () =>
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
|
||||
);
|
||||
}),
|
||||
});
|
||||
const { json } = await call(handler);
|
||||
assert.equal(json.feeds[0].status, 'unreachable');
|
||||
});
|
||||
|
||||
test('requests within a second share one read of every feed', async () => {
|
||||
let clock = FIXTURE_NOW_MS;
|
||||
let fetches = 0;
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: `1090=${FEED_1090}`,
|
||||
now: () => clock,
|
||||
logger: quietLogger(),
|
||||
fetchImpl: async () => {
|
||||
fetches += 1;
|
||||
return respond();
|
||||
},
|
||||
});
|
||||
await Promise.all([call(handler), call(handler), call(handler)]);
|
||||
assert.equal(fetches, 1, 'concurrent requests are single-flight');
|
||||
clock += 500;
|
||||
await call(handler);
|
||||
assert.equal(fetches, 1, 'cached for about a second');
|
||||
clock += 1_000;
|
||||
await call(handler);
|
||||
assert.equal(fetches, 2);
|
||||
const res = await call(handler, 'POST');
|
||||
assert.equal(res.status, 405);
|
||||
});
|
||||
|
||||
test('a feed name is resolved, every address checked, and the connection pinned to it', async () => {
|
||||
const lookups = [];
|
||||
const answers = {
|
||||
'good.local': [{ address: '10.0.0.9', family: 4 }],
|
||||
'public.local': [{ address: '93.184.216.34', family: 4 }],
|
||||
'linklocal.local': [{ address: '169.254.169.254', family: 4 }],
|
||||
'mixed.local': [
|
||||
{ address: '192.168.1.4', family: 4 },
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
],
|
||||
};
|
||||
const fetched = [];
|
||||
const logger = quietLogger();
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: [
|
||||
'1090=http://good.local:8080/data/aircraft.json',
|
||||
'1090=http://public.local:8080/data/aircraft.json',
|
||||
'978=http://linklocal.local/data/aircraft.json',
|
||||
'978=http://mixed.local/data/aircraft.json',
|
||||
`1090=${FEED_978}`,
|
||||
].join(','),
|
||||
now: () => FIXTURE_NOW_MS,
|
||||
logger,
|
||||
lookupImpl: async (hostname, options) => {
|
||||
lookups.push({ hostname, all: options?.all });
|
||||
return answers[hostname];
|
||||
},
|
||||
fetchImpl: async (url, options) => {
|
||||
fetched.push({ url, lookup: options.lookup });
|
||||
return respond();
|
||||
},
|
||||
});
|
||||
const { json } = await call(handler);
|
||||
assert.deepEqual(
|
||||
json.feeds.map(({ status }) => status),
|
||||
['live', 'unreachable', 'unreachable', 'unreachable', 'live'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
lookups.map(({ hostname }) => hostname).sort(),
|
||||
['good.local', 'linklocal.local', 'mixed.local', 'public.local'],
|
||||
'IP literals are not resolved',
|
||||
);
|
||||
assert.ok(
|
||||
lookups.every(({ all }) => all === true),
|
||||
'every address',
|
||||
);
|
||||
assert.deepEqual(
|
||||
fetched.map(({ url }) => url).sort(),
|
||||
[FEED_978, 'http://good.local:8080/data/aircraft.json'],
|
||||
'a name resolving outside loopback/RFC1918 is never contacted',
|
||||
);
|
||||
// The connection resolves only to the validated address, whatever the
|
||||
// resolver would answer later.
|
||||
answers['good.local'] = [{ address: '93.184.216.34', family: 4 }];
|
||||
const pinned = fetched.find(({ url }) => url.includes('good.local')).lookup;
|
||||
assert.equal(typeof pinned, 'function');
|
||||
const one = await new Promise((resolve) =>
|
||||
pinned('good.local', {}, (error, address, family) =>
|
||||
resolve({ error, address, family }),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(one, { error: null, address: '10.0.0.9', family: 4 });
|
||||
const all = await new Promise((resolve) =>
|
||||
pinned('good.local', { all: true }, (error, addresses) =>
|
||||
resolve(addresses),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(all, [{ address: '10.0.0.9', family: 4 }]);
|
||||
assert.ok(logger.lines.some((line) => /FORBIDDEN_ADDRESS/.test(line)));
|
||||
});
|
||||
|
||||
test('the default transport connects to the validated address, not a re-resolution', async (t) => {
|
||||
const { createServer } = await import('node:http');
|
||||
const server = createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ now: FIXTURE_NOW_MS / 1000, aircraft: [] }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => server.close());
|
||||
const { port } = server.address();
|
||||
// `receiver-pin-test.local` has no real DNS entry: only the pinned lookup
|
||||
// can reach the loopback server.
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: `1090=http://receiver-pin-test.local:${port}/data/aircraft.json`,
|
||||
now: () => FIXTURE_NOW_MS,
|
||||
logger: quietLogger(),
|
||||
lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||
});
|
||||
const { json } = await call(handler);
|
||||
assert.equal(json.feeds[0].status, 'live');
|
||||
});
|
||||
|
||||
test('null-body statuses (204, 205, 304) from a named feed are a clean feed error, never an uncaught exception', async (t) => {
|
||||
const { createServer } = await import('node:http');
|
||||
const server = createServer((req, res) => {
|
||||
const status = Number(req.url.split('/')[1]);
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => server.close());
|
||||
const { port } = server.address();
|
||||
const uncaught = [];
|
||||
const onUncaught = (error) => uncaught.push(error);
|
||||
process.on('uncaughtException', onUncaught);
|
||||
t.after(() => process.off('uncaughtException', onUncaught));
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: [204, 205, 304]
|
||||
.map(
|
||||
(status) =>
|
||||
`1090=http://receiver-null-body.local:${port}/${status}/aircraft.json`,
|
||||
)
|
||||
.join(','),
|
||||
now: () => FIXTURE_NOW_MS,
|
||||
logger: quietLogger(),
|
||||
timeoutMs: 300,
|
||||
lookupImpl: async () => [{ address: '127.0.0.1', family: 4 }],
|
||||
});
|
||||
const { status, json } = await call(handler);
|
||||
// Let any exception thrown inside the response callback surface.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.deepEqual(uncaught, [], 'no uncaught exception');
|
||||
assert.equal(status, 200);
|
||||
assert.deepEqual(
|
||||
json.feeds.map((feed) => feed.status),
|
||||
['unreachable', 'unreachable', 'unreachable'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a stalled DNS lookup fails its feed at the deadline without holding other feeds or later polls', async () => {
|
||||
let clock = FIXTURE_NOW_MS;
|
||||
const lookups = [
|
||||
// Never answers.
|
||||
() => new Promise(() => {}),
|
||||
// Answers only after the deadline: the late answer is ignored.
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve([{ address: '127.0.0.1', family: 4 }]), 80),
|
||||
),
|
||||
];
|
||||
const fetched = [];
|
||||
const handler = createLocalReceiversHandler({
|
||||
feedsValue: `1090=http://stalled.local/data/aircraft.json,978=${FEED_978}`,
|
||||
now: () => clock,
|
||||
logger: quietLogger(),
|
||||
timeoutMs: 20,
|
||||
lookupImpl: () => lookups.shift()(),
|
||||
fetchImpl: async (url) => {
|
||||
fetched.push(url);
|
||||
return respond();
|
||||
},
|
||||
});
|
||||
const within = (promise) =>
|
||||
Promise.race([
|
||||
promise,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timed out'), 60)),
|
||||
]);
|
||||
const first = await within(call(handler));
|
||||
assert.notEqual(first, 'timed out', 'the snapshot is not held by DNS');
|
||||
assert.deepEqual(
|
||||
first.json.feeds.map((feed) => feed.status),
|
||||
['unreachable', 'live'],
|
||||
);
|
||||
clock += 2_000;
|
||||
const second = await within(call(handler));
|
||||
assert.notEqual(second, 'timed out', 'a later poll is not held either');
|
||||
assert.deepEqual(
|
||||
second.json.feeds.map((feed) => feed.status),
|
||||
['unreachable', 'live'],
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.ok(
|
||||
fetched.every((url) => !url.includes('stalled.local')),
|
||||
'a late DNS answer is never fetched',
|
||||
);
|
||||
});
|
||||
@@ -3477,3 +3477,12 @@ test('row info and legends reconcile in place while chips retain focus and order
|
||||
else globalThis.document = originalDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshLayerStats reaches presentation through the bare lifecycle', async () => {
|
||||
const { LayerLifecycle } = await import('./lifecycle.js');
|
||||
const lifecycle = new LayerLifecycle({});
|
||||
const changes = [];
|
||||
lifecycle.subscribeActivity((change) => changes.push(change.type));
|
||||
lifecycle.refreshLayerStats();
|
||||
assert.deepEqual(changes, ['status']);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared 3D-model eligibility for the aircraft layers.
|
||||
*
|
||||
* Eligibility is by DISTANCE (the mode's add/keep band) with ON-SCREEN
|
||||
* PRIORITY under the cap, in four visible-first passes so the slots are spent
|
||||
* on what the operator can see:
|
||||
* 1. KEEP on-screen contacts that already have a model (hysteresis for
|
||||
* visible planes);
|
||||
* 2. ADD on-screen new contacts inside the add radius, nearest first;
|
||||
* 3. KEEP off-screen contacts that already have a model;
|
||||
* 4. ADD off-screen new contacts inside the add radius with leftover slots.
|
||||
* KEEP is split by frustum so an off-screen retained model can never starve
|
||||
* an on-screen contact that wants one.
|
||||
*
|
||||
* Portable: no Cesium, DOM or layer state.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Array<[string, number, boolean]>} candidates `[id, distanceSq,
|
||||
* onScreen]` for every contact inside the KEEP radius, sorted nearest first.
|
||||
* @param {object} options
|
||||
* @param {number} options.cap Maximum number of eligible contacts.
|
||||
* @param {number} options.addDistSq Squared ADD radius (m²).
|
||||
* @param {(id: string) => boolean} options.isModeled Whether the contact
|
||||
* already has a model (live, not merely pending).
|
||||
* @returns {Set<string>} Contacts that may hold a model this tick.
|
||||
*/
|
||||
export function selectModelEligible(candidates, { cap, addDistSq, isModeled }) {
|
||||
const eligible = new Set();
|
||||
const passes = [
|
||||
(onScreen, modeled) => onScreen && modeled,
|
||||
(onScreen, modeled, inAdd) => onScreen && inAdd,
|
||||
(onScreen, modeled) => !onScreen && modeled,
|
||||
(onScreen, modeled, inAdd) => !onScreen && inAdd,
|
||||
];
|
||||
for (const admit of passes) {
|
||||
for (const [id, distanceSq, onScreen] of candidates) {
|
||||
if (eligible.size >= cap) return eligible;
|
||||
if (eligible.has(id)) continue;
|
||||
if (admit(onScreen, isModeled(id), distanceSq <= addDistSq))
|
||||
eligible.add(id);
|
||||
}
|
||||
}
|
||||
return eligible;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { selectModelEligible } from './modelEligibility.js';
|
||||
|
||||
const ADD_SQ = 100 * 100;
|
||||
|
||||
test('visible contacts win the cap before off-screen retained models', () => {
|
||||
// Nearest first: an off-screen retained model, then two on-screen contacts.
|
||||
const candidates = [
|
||||
['off-kept', 10, false],
|
||||
['on-new', 20, true],
|
||||
['on-kept', 30, true],
|
||||
];
|
||||
const modeled = new Set(['off-kept', 'on-kept']);
|
||||
const eligible = selectModelEligible(candidates, {
|
||||
cap: 2,
|
||||
addDistSq: ADD_SQ,
|
||||
isModeled: (id) => modeled.has(id),
|
||||
});
|
||||
assert.deepEqual([...eligible], ['on-kept', 'on-new']);
|
||||
});
|
||||
|
||||
test('retained models keep their slot beyond the add radius; new ones do not', () => {
|
||||
const candidates = [
|
||||
['near-new', 50, true],
|
||||
['far-kept', ADD_SQ + 1, true],
|
||||
['far-new', ADD_SQ + 2, true],
|
||||
['off-new', 60, false],
|
||||
];
|
||||
const eligible = selectModelEligible(candidates, {
|
||||
cap: 10,
|
||||
addDistSq: ADD_SQ,
|
||||
isModeled: (id) => id === 'far-kept',
|
||||
});
|
||||
assert.deepEqual([...eligible], ['far-kept', 'near-new', 'off-new']);
|
||||
});
|
||||
|
||||
test('an empty cap admits nothing', () => {
|
||||
assert.equal(
|
||||
selectModelEligible([['a', 1, true]], {
|
||||
cap: 0,
|
||||
addDistSq: ADD_SQ,
|
||||
isModeled: () => true,
|
||||
}).size,
|
||||
0,
|
||||
);
|
||||
});
|
||||
@@ -11,11 +11,12 @@
|
||||
*
|
||||
* PURE data module — no Cesium imports, node-testable. The packs are lazy-
|
||||
* loaded on first lookup and cached in module scope (bbox/area computed once
|
||||
* at load). In the browser Vite bundles the JSON via dynamic import; under
|
||||
* node the same files are read from disk. A failed load is retried on the
|
||||
* next lookup rather than cached (see `createRetryableLoader`).
|
||||
* at load) through `loadBundledJson`, which works in the browser and under
|
||||
* node:test. A failed load is retried on the next lookup rather than cached
|
||||
* (see `createRetryableLoader`).
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
@@ -120,20 +121,22 @@ function suffixVariants(norm) {
|
||||
/** @type {Array|null} flat entry list for listRegions() */
|
||||
let _entries = null;
|
||||
|
||||
async function loadPackFile(base) {
|
||||
// Vite bundles these JSON files as modules; the import attribute is what Node
|
||||
// needs to load the same files under node:test (same pattern as
|
||||
// neighborhoodPolygons.js). One path, so no node: import reaches the browser.
|
||||
const mod =
|
||||
base === 'regions'
|
||||
? await import('./local_data/natural_earth/regions.json', {
|
||||
with: { type: 'json' },
|
||||
})
|
||||
: await import('./local_data/natural_earth/marine.json', {
|
||||
with: { type: 'json' },
|
||||
});
|
||||
return mod.default || mod;
|
||||
}
|
||||
const PACKS = {
|
||||
regions: {
|
||||
url: new URL('./local_data/natural_earth/regions.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/regions.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
marine: {
|
||||
url: new URL('./local_data/natural_earth/marine.json', import.meta.url),
|
||||
importJson: () =>
|
||||
import('./local_data/natural_earth/marine.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
function buildEntries(pack, kind) {
|
||||
const out = [];
|
||||
@@ -175,8 +178,8 @@ function buildEntries(pack, kind) {
|
||||
*/
|
||||
const loadIndex = createRetryableLoader(async () => {
|
||||
const [regions, marine] = await Promise.all([
|
||||
loadPackFile('regions'),
|
||||
loadPackFile('marine'),
|
||||
loadBundledJson(PACKS.regions.url, PACKS.regions.importJson),
|
||||
loadBundledJson(PACKS.marine.url, PACKS.marine.importJson),
|
||||
]);
|
||||
_entries = [
|
||||
...buildEntries(regions, 'natural'),
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* touching this module — see `local_data/neighborhoods/SOURCE.md`.
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
|
||||
@@ -17,7 +18,11 @@ const CITY_FILES = [
|
||||
{
|
||||
id: 'san-francisco',
|
||||
bbox: [-122.55, 37.7, -122.35, 37.84],
|
||||
loader: () =>
|
||||
url: new URL(
|
||||
'./local_data/neighborhoods/san-francisco.json',
|
||||
import.meta.url,
|
||||
),
|
||||
importJson: () =>
|
||||
import('./local_data/neighborhoods/san-francisco.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
@@ -107,10 +112,7 @@ function cityLoader(city) {
|
||||
let loader = _cityLoaders.get(city.id);
|
||||
if (!loader) {
|
||||
loader = createRetryableLoader(async () => {
|
||||
// One path for both runtimes: Vite bundles the JSON as a module, and the
|
||||
// import attribute is what Node needs to load the same file under node:test.
|
||||
const mod = await city.loader();
|
||||
const fc = mod.default || mod;
|
||||
const fc = await loadBundledJson(city.url, city.importJson);
|
||||
return Array.isArray(fc.features) ? fc.features : [];
|
||||
});
|
||||
_cityLoaders.set(city.id, loader);
|
||||
|
||||
@@ -1059,6 +1059,10 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
this.parentElement = null;
|
||||
}
|
||||
setAttribute() {}
|
||||
// This fixture has no parsed DOM children; it exercises native label
|
||||
// ownership, not the replay overlay's separately rendered icon children.
|
||||
querySelectorAll() { return []; }
|
||||
querySelector() { return null; }
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: 1600, height: 900 }; }
|
||||
getContext() { return this.tagName === 'CANVAS' ? context : null; }
|
||||
}
|
||||
|
||||
@@ -415,8 +415,10 @@ test('a converted contact never consumes a 3D model CAP SLOT', async () => {
|
||||
assert.match(loop, /keepDistSq/, `${name}: matched the model-eligibility loop`);
|
||||
assert.match(loop, /if \(isTr3b\(icao\)\) continue;/,
|
||||
`${name}: converted contacts are dropped BEFORE entering the capped candidate list`);
|
||||
// ...and the cap really is applied to that list, so a dropped candidate is a freed slot.
|
||||
assert.match(source, /modelEligible\.size >= cap/,
|
||||
// ...and the cap really is applied to that list, so a dropped candidate is a
|
||||
// freed slot. The four-pass selection itself is the shared
|
||||
// selectModelEligible (behavior-tested in modelEligibility.test.mjs).
|
||||
assert.match(source, /modelEligible\s*=\s*selectModelEligible\(\s*cand,\s*\{\s*cap,/,
|
||||
`${name}: the cap bounds the candidate-derived eligible set`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
|
||||
const READOUT_CONTEXT_LAYERS = new Set([
|
||||
'military-installations',
|
||||
'alpr-cameras',
|
||||
'local-adsb',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -661,11 +661,18 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
|
||||
// Canonical serialization pins every tool name, description, property and
|
||||
// ordering while allowing source formatting. Derived from the unchanged
|
||||
// release schema before formatting (the previous source-byte pin passed).
|
||||
const block = JSON.stringify(GEV_REALTIME_TOOLS);
|
||||
assert.equal(block.length, 27271, 'serialized tool schema length drifted');
|
||||
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
// Cyber deliberately adds one layout; first-run missions still change no tools.
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the additive `local-adsb` set_layer_visibility value and
|
||||
// its common-name mapping; the missions still ride existing tools.
|
||||
assert.equal(block.length, 27432, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918',
|
||||
'a2a4a787f4528f75b01f3f42caec636f29c37452c0d45b11f4f986171d6be57d',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
|
||||
+13
-7
@@ -30,6 +30,11 @@ import {
|
||||
hudTelemetryProvenanceTag,
|
||||
isHudSummaryUnconfigured,
|
||||
} from './hudSummaryResponse.js';
|
||||
import {
|
||||
applyHudUiTheme,
|
||||
DEFAULT_HUD_LAYOUT,
|
||||
normalizeHudLayout,
|
||||
} from './hudLayouts.js';
|
||||
|
||||
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
|
||||
const HUD_COLORS = {
|
||||
@@ -58,8 +63,6 @@ const HUD_COLORS = {
|
||||
/** Shader modes that automatically show the HUD overlay. */
|
||||
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
|
||||
|
||||
/** Allowed HUD layout variants. */
|
||||
const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']);
|
||||
const HUD_SUMMARY_INTERVAL_MS = 15000;
|
||||
|
||||
/**
|
||||
@@ -110,7 +113,7 @@ export class IntelHUD {
|
||||
this._autoMode = true; // auto show/hide based on style
|
||||
this._currentStyle = 'normal';
|
||||
this._el = null;
|
||||
this._variant = 'tactical';
|
||||
this._variant = DEFAULT_HUD_LAYOUT;
|
||||
this._recBlinkState = true;
|
||||
this._updateInterval = null;
|
||||
this._recBlinkInterval = null;
|
||||
@@ -183,6 +186,8 @@ export class IntelHUD {
|
||||
if (!this._el) return;
|
||||
|
||||
this._el.innerHTML = `
|
||||
<div class="hud-sonar" aria-hidden="true"></div>
|
||||
|
||||
<div class="hud-top-bar">
|
||||
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
|
||||
<span class="hud-top-bar-center">${this._missionId}</span>
|
||||
@@ -879,15 +884,16 @@ export class IntelHUD {
|
||||
|
||||
/**
|
||||
* Switch the HUD layout variant. Falls back to `'tactical'` if the
|
||||
* name is unrecognized.
|
||||
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`.
|
||||
* name is unrecognized. Cyber also applies a coordinated skin to the full
|
||||
* application shell; leaving Cyber restores the standard shell tokens.
|
||||
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
|
||||
*/
|
||||
setVariant(variantName) {
|
||||
const normalized = String(variantName || '').toLowerCase();
|
||||
this._variant = HUD_VARIANTS.has(normalized) ? normalized : 'tactical';
|
||||
this._variant = normalizeHudLayout(variantName);
|
||||
if (this._el) {
|
||||
this._el.dataset.variant = this._variant;
|
||||
}
|
||||
applyHudUiTheme(document.documentElement, this._variant);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
|
||||
export const HUD_LAYOUTS = Object.freeze([
|
||||
'tactical',
|
||||
'operator',
|
||||
'minimal',
|
||||
'cyber',
|
||||
]);
|
||||
@@ -0,0 +1,64 @@
|
||||
import { applyCyberSonarThemePhase } from './cyberSonar.js';
|
||||
import { syncRadioPanelPlacement } from './ui/radioPanelPlacement.js';
|
||||
import { HUD_LAYOUTS } from './hudLayoutPolicy.js';
|
||||
export { HUD_LAYOUTS } from './hudLayoutPolicy.js';
|
||||
|
||||
/** Canonical HUD layouts shared by the UI, voice schema and HUD renderer. */
|
||||
|
||||
export const DEFAULT_HUD_LAYOUT = 'tactical';
|
||||
|
||||
/** Visual treatment applied only when an operator explicitly enters Cyber. */
|
||||
export const CYBER_VISUAL_DEFAULTS = Object.freeze({
|
||||
style: 'thermal',
|
||||
ironbow: 0.42,
|
||||
});
|
||||
|
||||
const HUD_LAYOUT_SET = new Set(HUD_LAYOUTS);
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function isHudLayout(value) {
|
||||
return HUD_LAYOUT_SET.has(String(value ?? '').toLowerCase());
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
export function normalizeHudLayout(value) {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
return isHudLayout(normalized) ? normalized : DEFAULT_HUD_LAYOUT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the opt-in visual defaults for an explicit layout transition.
|
||||
* Restores and repeated Cyber selections return null so they never overwrite
|
||||
* a shared link or a user's subsequent FLIR tuning.
|
||||
*/
|
||||
export function cyberVisualDefaultsForHudTransition(
|
||||
previousLayout,
|
||||
nextLayout,
|
||||
{ explicit = false } = {},
|
||||
) {
|
||||
if (!explicit) return null;
|
||||
if (normalizeHudLayout(previousLayout) === 'cyber') return null;
|
||||
if (normalizeHudLayout(nextLayout) !== 'cyber') return null;
|
||||
return CYBER_VISUAL_DEFAULTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the global shell skin derived from the selected HUD layout. The data
|
||||
* attribute lives on <html> so theme tokens are available to every UI surface.
|
||||
* @param {HTMLElement|{dataset?: Record<string, string>}|null|undefined} root
|
||||
* @param {unknown} layout
|
||||
* @returns {'cyber'|null}
|
||||
*/
|
||||
export function applyHudUiTheme(root, layout) {
|
||||
if (!root?.dataset) return null;
|
||||
if (normalizeHudLayout(layout) === 'cyber') {
|
||||
root.dataset.uiTheme = 'cyber';
|
||||
applyCyberSonarThemePhase(root, true);
|
||||
syncRadioPanelPlacement(root.ownerDocument);
|
||||
return 'cyber';
|
||||
}
|
||||
delete root.dataset.uiTheme;
|
||||
applyCyberSonarThemePhase(root, false);
|
||||
syncRadioPanelPlacement(root.ownerDocument);
|
||||
return null;
|
||||
}
|
||||
+28
-4
@@ -147,10 +147,29 @@ function buildRow(documentRef, key) {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
|
||||
* the surface has no business existing (prod build, LAN visitor, no markup).
|
||||
*/
|
||||
/** Move the existing setup action with the theme, retaining its listeners. */
|
||||
export function bindKeySetupPlacement(documentRef, chip, root) {
|
||||
const theme = documentRef?.documentElement;
|
||||
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
||||
const Observer = documentRef?.defaultView?.MutationObserver;
|
||||
if (!theme || !toolbar || !Observer) return () => {};
|
||||
const sync = () => {
|
||||
if (theme.dataset.uiTheme === 'cyber') {
|
||||
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
||||
} else if (chip.parentNode !== root.parentNode) {
|
||||
root.before(chip);
|
||||
}
|
||||
};
|
||||
const observer = new Observer(sync);
|
||||
observer.observe(theme, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-ui-theme'],
|
||||
});
|
||||
sync();
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
|
||||
export async function initKeySetup({
|
||||
documentRef = globalThis.document,
|
||||
fetchImpl,
|
||||
@@ -163,12 +182,14 @@ export async function initKeySetup({
|
||||
const lifetime = new AbortController();
|
||||
let disposed = false;
|
||||
let disposeControls = () => {};
|
||||
let disposePlacement = () => {};
|
||||
const destroy = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lifetime.abort();
|
||||
signal?.removeEventListener('abort', destroy);
|
||||
disposeControls();
|
||||
disposePlacement();
|
||||
chip.remove();
|
||||
root.remove();
|
||||
};
|
||||
@@ -196,6 +217,7 @@ export async function initKeySetup({
|
||||
}
|
||||
|
||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
||||
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
|
||||
const applyButton = root.querySelector('[data-key-setup-apply]');
|
||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
||||
@@ -208,6 +230,8 @@ export async function initKeySetup({
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
chipLabel.textContent = keySetupChipLabel(status);
|
||||
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
|
||||
chip.setAttribute('aria-label', chip.title);
|
||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||
chip.hidden = status.setCount >= status.total;
|
||||
|
||||
@@ -4,8 +4,43 @@ import {
|
||||
collectKeyUpdates,
|
||||
keySetupChipLabel,
|
||||
stripKeylessBasemapFromHash,
|
||||
bindKeySetupPlacement,
|
||||
} from './keySetup.js';
|
||||
|
||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
||||
const home = {};
|
||||
const chip = { parentNode: home, hidden: true };
|
||||
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
|
||||
let moves = 0;
|
||||
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
|
||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
||||
let notify, disconnected = false;
|
||||
class Observer {
|
||||
constructor(callback) { notify = callback; }
|
||||
observe(target, options) {
|
||||
assert.equal(target, theme);
|
||||
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
||||
}
|
||||
disconnect() { disconnected = true; }
|
||||
}
|
||||
const dispose = bindKeySetupPlacement({
|
||||
documentElement: theme,
|
||||
getElementById: () => toolbar,
|
||||
defaultView: { MutationObserver: Observer },
|
||||
}, chip, root);
|
||||
assert.equal(chip.parentNode, home);
|
||||
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
|
||||
theme.dataset.uiTheme = variant;
|
||||
notify();
|
||||
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
|
||||
notify();
|
||||
}
|
||||
assert.equal(moves, 2, 'identical theme sync does not reparent again');
|
||||
assert.equal(chip.hidden, true, 'placement cannot reveal a retired button');
|
||||
dispose();
|
||||
assert.equal(disconnected, true);
|
||||
});
|
||||
|
||||
test('the chip counts what is missing, and retires the count at zero', () => {
|
||||
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
||||
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
import {
|
||||
formatAwarenessLabel,
|
||||
formatAwarenessDistance,
|
||||
@@ -105,6 +109,9 @@ export function createRendering({
|
||||
const canvas = layerState.viewer.scene.canvas;
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
const sonar = isCyberSonarActive()
|
||||
? createCyberSonarSampler(width, height)
|
||||
: null;
|
||||
const geometry = getKeyholeGeometry(width, height);
|
||||
if (!(geometry.radius > 0)) {
|
||||
root.hidden = true;
|
||||
@@ -182,6 +189,15 @@ export function createRendering({
|
||||
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
|
||||
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
|
||||
marker.style.opacity = String(projection.opacity);
|
||||
const sonarFactor =
|
||||
sonar?.at(
|
||||
geometry.centerX + Math.cos(projection.angle) * markerRadius,
|
||||
geometry.centerY + Math.sin(projection.angle) * markerRadius,
|
||||
) ?? 1;
|
||||
marker._arrow.style.opacity = String(sonarFactor);
|
||||
marker._label.style.opacity = String(
|
||||
sonar ? sonar.label(sonarFactor) : 1,
|
||||
);
|
||||
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
|
||||
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
|
||||
const label = formatAwarenessLabel(item);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_MODEL_URL,
|
||||
CLASS_SCALE_3D,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
trailAnchorForModel,
|
||||
visualCenterForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* The glTF asset, scale and measured geometry a civil aircraft class renders
|
||||
* with. Real per-class GLBs are baked to real-world meters (scale 1, measured
|
||||
* belly and radius); classes without one share airplane.glb at the class's
|
||||
* 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw
|
||||
* the same model for the same class.
|
||||
* @param {string} klass Aircraft class from `classifyAircraft`.
|
||||
* @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number,
|
||||
* blendAmount:number, visualCenterNative:object, trailAnchorNative:object}}
|
||||
*/
|
||||
export function civilAircraftModelSpec(klass) {
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
if (real) {
|
||||
return {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
}
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
|
||||
return {
|
||||
url,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CLASS_SCALE_2D,
|
||||
CLASS_MODEL_REAL,
|
||||
CLASS_SCALE_3D,
|
||||
CLASS_MODEL_URL,
|
||||
} from '../../data/aircraftClass.js';
|
||||
import {
|
||||
visualCenterForModel,
|
||||
trailAnchorForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
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 { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -20,11 +14,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
|
||||
import {
|
||||
MIL_TINT,
|
||||
GROUND_SCALE,
|
||||
MODEL_COLOR_BLEND_AMOUNT,
|
||||
MODEL_SCALE,
|
||||
PLANE_MODEL_URL,
|
||||
MODEL_NATIVE_RADIUS_M,
|
||||
MODEL_BELLY_OFFSET_NATIVE,
|
||||
COCKPIT_CONTACT_SIZE_PX,
|
||||
COCKPIT_CIVILIAN_COLOR,
|
||||
MODEL_ALT_CEIL_M,
|
||||
@@ -100,31 +89,7 @@ export function createRendering({
|
||||
function _modelSpec(klass) {
|
||||
const cached = flightState._specCache.get(klass);
|
||||
if (cached) return cached;
|
||||
const real = CLASS_MODEL_REAL[klass];
|
||||
let spec;
|
||||
if (real) {
|
||||
spec = {
|
||||
url: real.url,
|
||||
scale: 1,
|
||||
nativeRadiusM: real.radiusM,
|
||||
bellyM: real.bellyM,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(real.url),
|
||||
trailAnchorNative: trailAnchorForModel(real.url),
|
||||
};
|
||||
} else {
|
||||
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
|
||||
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
|
||||
spec = {
|
||||
url,
|
||||
scale,
|
||||
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
|
||||
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
|
||||
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
|
||||
visualCenterNative: visualCenterForModel(url),
|
||||
trailAnchorNative: trailAnchorForModel(url),
|
||||
};
|
||||
}
|
||||
const spec = civilAircraftModelSpec(klass);
|
||||
flightState._specCache.set(klass, spec);
|
||||
return spec;
|
||||
}
|
||||
@@ -158,7 +123,7 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
|
||||
if (isCockpitContact && !isCockpitNear) {
|
||||
const freshnessAlpha = bb.color?.alpha ?? 1;
|
||||
const freshnessAlpha = cyberSonarBaseAlpha(bb);
|
||||
bb.image = cockpitContactDotImage();
|
||||
bb.width = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
@@ -180,7 +145,7 @@ export function createRendering({
|
||||
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
||||
bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale;
|
||||
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1);
|
||||
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -953,31 +918,11 @@ export function createRendering({
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = new Set();
|
||||
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
}
|
||||
// 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap).
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
}
|
||||
// 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a
|
||||
// retained off-screen model can never starve an on-screen plane that wants one; dropping it is
|
||||
// invisible and it re-adds the moment it's back in view).
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
}
|
||||
// 4. ADD off-screen NEW inside the add radius with any leftover slots.
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
}
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
@@ -1062,12 +1007,13 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor =
|
||||
const layerBaseColor =
|
||||
flightState._cockpitContactMode && !isCockpitNear
|
||||
? isMilitaryIcao(icao24)
|
||||
? MIL_TINT
|
||||
: COCKPIT_CIVILIAN_COLOR
|
||||
: _fleetBillboardColor(icao24);
|
||||
const baseColor = layerBaseColor;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import {
|
||||
turnRateFromFixHistory,
|
||||
liftRepeatedGroundFix,
|
||||
@@ -165,7 +166,7 @@ export function createFlightSnapshotRenderer({
|
||||
// freshness × focus × horizon alpha composition.
|
||||
bb.color = rendering
|
||||
._fleetBillboardColor(icao24)
|
||||
.withAlpha(bb.color?.alpha ?? 1);
|
||||
.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
if (
|
||||
prevMeta?.klass !== meta.klass ||
|
||||
|
||||
@@ -250,6 +250,8 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||
|
||||
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchWarmupTime = new Cesium.JulianDate();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
} from '../../cyberSonar.js';
|
||||
import {
|
||||
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
|
||||
ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID,
|
||||
@@ -303,6 +307,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
<span data-replay-overlay-detail></span>
|
||||
</div>`;
|
||||
host.appendChild(layerState._replayVehicleOverlay);
|
||||
layerState._replayVehicleOverlay._sonarIcons = [
|
||||
...layerState._replayVehicleOverlay.querySelectorAll(
|
||||
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
|
||||
),
|
||||
];
|
||||
layerState._replayVehicleOverlay._sonarLabel =
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'.mission-replay-overlay-callout',
|
||||
);
|
||||
}
|
||||
|
||||
function hideReplayVehicleOverlay() {
|
||||
@@ -502,6 +515,17 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
: windowPosition;
|
||||
track.lastOverlayWindowPosition = renderedWindowPosition;
|
||||
track.lastOverlayMode = mode;
|
||||
const sonar = isCyberSonarActive()
|
||||
? createCyberSonarSampler(canvas.clientWidth, canvas.clientHeight)
|
||||
: null;
|
||||
const factor =
|
||||
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
|
||||
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
|
||||
icon.style.opacity = String(factor);
|
||||
if (layerState._replayVehicleOverlay._sonarLabel)
|
||||
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
|
||||
sonar ? sonar.label(factor) : 1,
|
||||
);
|
||||
layerState._replayVehicleOverlay.hidden = false;
|
||||
layerState._replayVehicleOverlay.dataset.mode = mode;
|
||||
layerState._replayVehicleOverlay.classList.toggle(
|
||||
|
||||
@@ -268,7 +268,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
index < 0 || index >= layerState._launches.length - 1;
|
||||
parts.replay.syncReplayButton();
|
||||
const panelScroller = layerState._missionPanel.closest(
|
||||
'.global-context-panel-inner',
|
||||
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
|
||||
);
|
||||
if (panelScroller) panelScroller.scrollTop = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
|
||||
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
|
||||
for (const cyber of [true, false]) {
|
||||
const body = { scrollTop: 180 };
|
||||
const inner = { scrollTop: 90 };
|
||||
const output = { closest: () => null, removeAttribute() {} };
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector: () => output,
|
||||
closest(selector) {
|
||||
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
|
||||
return cyber ? body : inner;
|
||||
},
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
const panel = createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
},
|
||||
});
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(body.scrollTop, cyber ? 0 : 180);
|
||||
assert.equal(inner.scrollTop, cyber ? 90 : 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
BAND_LABELS,
|
||||
HEARD_BY_RECEIVER,
|
||||
LOCAL_ADSB_COLOR,
|
||||
SOURCE_LABELS,
|
||||
} from './policy.js';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
function rounded(value, digits = 0) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Number(value.toFixed(digits));
|
||||
}
|
||||
|
||||
function grouped(value) {
|
||||
return value === null ? DASH : value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/**
|
||||
* Title of a locally heard aircraft: callsign when decoded, else ICAO hex.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbTitle(record) {
|
||||
return record?.callsign || String(record?.icao || '').toUpperCase();
|
||||
}
|
||||
|
||||
function recordBands(record) {
|
||||
if (Array.isArray(record?.bands) && record.bands.length) return record.bands;
|
||||
return record?.band ? [record.band] : [];
|
||||
}
|
||||
|
||||
function recordSources(record) {
|
||||
if (Array.isArray(record?.sources) && record.sources.length)
|
||||
return record.sources;
|
||||
return record?.source ? [record.source] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft was heard only on 978 MHz UAT (its marker gets the
|
||||
* UAT ring). One also heard on 1090 MHz draws as a 1090 aircraft.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function localAdsbIsUat(record) {
|
||||
const bands = recordBands(record);
|
||||
return bands.includes('978') && !bands.includes('1090');
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver line of the click card, e.g. "Heard by your receiver · 1090 MHz ·
|
||||
* browser SDR" or "… · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed".
|
||||
* @param {object} record Local ADS-B record (merged: `bands`, `sources`).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbReceiverLine(record) {
|
||||
const bands = recordBands(record)
|
||||
.map((band) => BAND_LABELS[band])
|
||||
.filter(Boolean);
|
||||
const sources = recordSources(record)
|
||||
.map((source) => SOURCE_LABELS[source])
|
||||
.filter(Boolean);
|
||||
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a heard aircraft came from, for the entity context's source field.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbSourceText(record) {
|
||||
const sources = recordSources(record);
|
||||
if (sources.includes('webusb') && sources.includes('feed'))
|
||||
return 'Your RTL-SDR receiver and decoder feed';
|
||||
if (sources.includes('feed')) return 'Your decoder feed';
|
||||
return 'Your RTL-SDR receiver';
|
||||
}
|
||||
|
||||
/**
|
||||
* Class line of the click card, e.g. "Helicopter · A7". Empty when neither an
|
||||
* ICAO type nor an emitter category backs the class.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {{klass?:string, evidence?:boolean}} [aircraftClass]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbClassLine(record, aircraftClass = {}) {
|
||||
if (!aircraftClass.evidence) return '';
|
||||
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
|
||||
return [label, record?.category].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-card presentation for one locally heard aircraft.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {number} nowMs Current epoch ms, for the position age.
|
||||
* @param {object} [extras]
|
||||
* @param {{klass:string, evidence:boolean}} [extras.aircraftClass] Class and
|
||||
* whether a type or category backs it.
|
||||
* @param {object|null} [extras.meta] adsbdb metadata (typeCode, typeName,
|
||||
* airline).
|
||||
* @param {object|null} [extras.route] A route already judged plausible.
|
||||
* @returns {{title:string, details:string[], accent:string}}
|
||||
*/
|
||||
export function localAdsbCardModel(
|
||||
record,
|
||||
nowMs,
|
||||
{ aircraftClass = null, meta = null, route = null } = {},
|
||||
) {
|
||||
const icao = String(record?.icao || '').toUpperCase();
|
||||
const altitude = rounded(record?.altitudeFt);
|
||||
const speed = rounded(record?.groundSpeedKt);
|
||||
const track = rounded(record?.trackDeg);
|
||||
const vertical = rounded(record?.verticalRateFpm);
|
||||
const ageS = Number.isFinite(record?.lastPositionAt)
|
||||
? Math.max(0, Math.round((nowMs - record.lastPositionAt) / 1000))
|
||||
: null;
|
||||
const messages = Math.max(0, Math.trunc(Number(record?.messageCount) || 0));
|
||||
// Same identity and route lines the public flight card shows.
|
||||
const identity = [meta?.airline, meta?.typeName || meta?.typeCode]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const routeLine =
|
||||
route?.origin?.code && route?.destination?.code
|
||||
? `${route.origin.code} → ${route.destination.code}`
|
||||
: '';
|
||||
return {
|
||||
title: localAdsbTitle(record),
|
||||
details: [
|
||||
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
|
||||
localAdsbClassLine(record, aircraftClass || {}),
|
||||
identity,
|
||||
routeLine,
|
||||
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
|
||||
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
|
||||
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
|
||||
localAdsbReceiverLine(record),
|
||||
].filter(Boolean),
|
||||
accent: LOCAL_ADSB_COLOR,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { stickyText } from '../../data/aircraftMeta.js';
|
||||
import {
|
||||
ENRICH_DISPATCH_GAP_MS,
|
||||
ENRICH_MAX_INFLIGHT,
|
||||
} from '../flights/policy.js';
|
||||
|
||||
const ICAO = /^[0-9a-f]{6}$/i;
|
||||
/** Airline-style callsigns only (LLL + digit); GA tails do not resolve. */
|
||||
const AIRLINE_CALLSIGN = /^[A-Z]{3}\d/;
|
||||
/** Bound on the per-session metadata map. */
|
||||
const META_LIMIT = 2_000;
|
||||
|
||||
/**
|
||||
* Class of a locally heard aircraft: the adsbdb ICAO type when known, else
|
||||
* the emitter category it broadcasts. `evidence` is false when neither is
|
||||
* known, so the card never presents the classifier's default as fact.
|
||||
* @param {{category?:string|null}} record
|
||||
* @param {{typeCode?:string|null}} [meta]
|
||||
* @returns {{klass:string, evidence:boolean}}
|
||||
*/
|
||||
export function localAdsbClass(record, meta = null) {
|
||||
const typeCode = meta?.typeCode || null;
|
||||
const category = record?.category || null;
|
||||
return {
|
||||
klass: classifyAircraft({ typeCode, category }),
|
||||
evidence: Boolean(typeCode) || /^(?:A[1-7]|B1)$/.test(category || ''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* adsbdb enrichment for Local ADS-B through the same cached proxy and the same
|
||||
* rules as the public Flights layer: each key is asked for at most once per
|
||||
* session, at most four requests are in flight and dispatches are dripped
|
||||
* 200 ms apart. Only the selected aircraft (type + route) and aircraft that
|
||||
* are about to render as 3D models (type) are ever looked up — never one
|
||||
* request per contact per tick.
|
||||
* @param {object} options
|
||||
* @param {{getEnrichment?: Function}|null} options.source Flights source.
|
||||
* @param {() => void} options.onChange Called when metadata arrives.
|
||||
* @param {() => number} [options.now]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createLocalAdsbEnrichment({
|
||||
source,
|
||||
onChange,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const meta = new Map();
|
||||
const seen = new Set();
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
let lastDispatchAt = -Infinity;
|
||||
let dripTimer = null;
|
||||
let lifetime = new AbortController();
|
||||
|
||||
function entry(icao) {
|
||||
let value = meta.get(icao);
|
||||
if (!value) {
|
||||
value = {};
|
||||
meta.set(icao, value);
|
||||
if (meta.size > META_LIMIT) meta.delete(meta.keys().next().value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function enqueue(key, query, apply, priority) {
|
||||
if (typeof source?.getEnrichment !== 'function' || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const job = { query, apply };
|
||||
if (priority) queue.unshift(job);
|
||||
else queue.push(job);
|
||||
drain();
|
||||
}
|
||||
|
||||
function drain() {
|
||||
const signal = lifetime.signal;
|
||||
while (!signal.aborted && active < ENRICH_MAX_INFLIGHT && queue.length) {
|
||||
const wait = ENRICH_DISPATCH_GAP_MS - (now() - lastDispatchAt);
|
||||
if (wait > 0) {
|
||||
if (!dripTimer)
|
||||
dripTimer = setTimeout(() => {
|
||||
dripTimer = null;
|
||||
drain();
|
||||
}, wait);
|
||||
return;
|
||||
}
|
||||
lastDispatchAt = now();
|
||||
const job = queue.shift();
|
||||
active += 1;
|
||||
Promise.resolve()
|
||||
.then(() => source.getEnrichment(job.query, { signal }))
|
||||
.then((data) => {
|
||||
if (!signal.aborted && data?.found) {
|
||||
job.apply(data);
|
||||
onChange?.();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* enrichment never surfaces errors */
|
||||
})
|
||||
.finally(() => {
|
||||
if (signal.aborted) return;
|
||||
active -= 1;
|
||||
drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** @param {string} icao @returns {object|null} */
|
||||
get(icao) {
|
||||
return meta.get(String(icao || '').toLowerCase()) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask for the aircraft's type (and, when selected, its route).
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {{selected?: boolean}} [options]
|
||||
*/
|
||||
request(record, { selected = false } = {}) {
|
||||
const icao = String(record?.icao || '').toLowerCase();
|
||||
if (!ICAO.test(icao)) return;
|
||||
enqueue(
|
||||
`t:${icao}`,
|
||||
{ kind: 'type', id: icao },
|
||||
(data) => {
|
||||
const value = entry(icao);
|
||||
value.typeCode = stickyText(data.typeCode, value.typeCode) || null;
|
||||
value.typeName = stickyText(data.typeName, value.typeName) || null;
|
||||
value.registration =
|
||||
stickyText(data.registration, value.registration) || null;
|
||||
},
|
||||
selected,
|
||||
);
|
||||
const callsign = String(record.callsign || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!selected || !AIRLINE_CALLSIGN.test(callsign)) return;
|
||||
enqueue(
|
||||
`r:${callsign}`,
|
||||
{ kind: 'route', id: callsign },
|
||||
(data) => {
|
||||
const value = entry(icao);
|
||||
value.airline = stickyText(data.airline, value.airline) || null;
|
||||
if (data.origin && data.destination)
|
||||
value.route = {
|
||||
callsign,
|
||||
origin: data.origin,
|
||||
destination: data.destination,
|
||||
};
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
lifetime.abort();
|
||||
lifetime = new AbortController();
|
||||
clearTimeout(dripTimer);
|
||||
dripTimer = null;
|
||||
queue.length = 0;
|
||||
active = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Names for the server's decoder feeds, shared by the Local ADS-B row status
|
||||
* and the Radio card. Pure; no imports, so any surface may use it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display name of one feed: its band, or its ordinal label ("978 MHz UAT #2")
|
||||
* when another configured feed shares the band.
|
||||
* @param {object} feed Feed status from the route.
|
||||
* @param {object[]} [feeds] Every configured feed.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localReceiverFeedName(feed, feeds = []) {
|
||||
const band = feed?.band;
|
||||
if (!band) return feed?.label || 'feed';
|
||||
const sharing = feeds.filter((other) => other?.band === band);
|
||||
if (sharing.length < 2) return band;
|
||||
if (typeof feed.label === 'string' && feed.label.includes('#'))
|
||||
return feed.label;
|
||||
return `${feed.label || band} #${sharing.indexOf(feed) + 1}`;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
localAdsbRecordIsLive,
|
||||
localAdsbRecordIsNewer,
|
||||
} from '../../sources/adsbRecords.js';
|
||||
|
||||
/** Same-origin route served by `server/providers/local-receivers.js`. */
|
||||
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
|
||||
export const LOCAL_RECEIVER_POLL_MS = 1_000;
|
||||
/**
|
||||
* Browser-side deadline for one route request, body included. The server
|
||||
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
|
||||
*/
|
||||
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('Local receiver request aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Reject with an AbortError as soon as `signal` aborts. */
|
||||
function untilAborted(promise, signal) {
|
||||
if (signal.aborted) return Promise.reject(abortError());
|
||||
let onAbort = null;
|
||||
const aborted = new Promise((_, reject) => {
|
||||
onAbort = () => reject(abortError());
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
return Promise.race([promise, aborted]).finally(() =>
|
||||
signal.removeEventListener('abort', onAbort),
|
||||
);
|
||||
}
|
||||
|
||||
function shiftTime(value, offset) {
|
||||
return Number.isFinite(value) ? value + offset : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser poller for the server's local decoder feeds.
|
||||
*
|
||||
* Polls only between `start()` and `stop()` (the Local ADS-B layer calls them
|
||||
* on enable/disable), and only while the route reports `configured`: an
|
||||
* unconfigured server, or a build without the route, stops the loop after one
|
||||
* request. `probe()` makes that one request without polling, so the Radio card
|
||||
* can show which feeds exist before the layer is turned on.
|
||||
*
|
||||
* Records are rebased from the server clock to this browser's clock and kept
|
||||
* by ICAO and band for up to 60 s after their last message, so a feed that
|
||||
* drops an aircraft or goes briefly unreachable does not erase it at once.
|
||||
* @param {object} [options]
|
||||
* @param {typeof fetch} [options.fetchImpl]
|
||||
* @param {string} [options.url]
|
||||
* @param {number} [options.intervalMs]
|
||||
* @param {number} [options.requestTimeoutMs] Deadline per request.
|
||||
* @param {() => number} [options.now]
|
||||
* @returns {object} Feed session: start, stop, probe, getState, subscribe.
|
||||
*/
|
||||
export function createLocalReceiverFeeds({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
url = LOCAL_RECEIVERS_URL,
|
||||
intervalMs = LOCAL_RECEIVER_POLL_MS,
|
||||
requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS,
|
||||
now = Date.now,
|
||||
} = {}) {
|
||||
const listeners = new Set();
|
||||
const retained = new Map();
|
||||
/** Abort controllers of requests still in flight. */
|
||||
const inFlight = new Set();
|
||||
let state = {
|
||||
configured: null,
|
||||
polling: false,
|
||||
feeds: [],
|
||||
records: [],
|
||||
error: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let probing = null;
|
||||
|
||||
function emit(patch) {
|
||||
state = { ...state, ...patch };
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
|
||||
function retain(records, at) {
|
||||
for (const record of records) {
|
||||
if (!record?.icao) continue;
|
||||
// Two feeds on one band both report an aircraft: keep the fresher
|
||||
// record, never simply the one listed (or polled) last.
|
||||
const key = `${record.icao}|${record.band}`;
|
||||
const current = retained.get(key);
|
||||
if (!current || !localAdsbRecordIsNewer(current, record))
|
||||
retained.set(key, record);
|
||||
}
|
||||
for (const [key, record] of retained) {
|
||||
if (!localAdsbRecordIsLive(record, at)) retained.delete(key);
|
||||
}
|
||||
return [...retained.values()];
|
||||
}
|
||||
|
||||
/** One route request with a deadline through the body, abortable by stop. */
|
||||
async function request() {
|
||||
const controller = new AbortController();
|
||||
inFlight.add(controller);
|
||||
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
const response = await untilAborted(
|
||||
fetchImpl(url, {
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
}),
|
||||
controller.signal,
|
||||
);
|
||||
if (!response?.ok) throw new Error('local receiver route unavailable');
|
||||
return await untilAborted(response.json(), controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
inFlight.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function abortInFlight() {
|
||||
for (const controller of inFlight) controller.abort();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
function apply(body) {
|
||||
if (body?.configured !== true) {
|
||||
retained.clear();
|
||||
emit({
|
||||
configured: false,
|
||||
polling: false,
|
||||
feeds: [],
|
||||
records: [],
|
||||
error: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const at = now();
|
||||
const offset = Number.isFinite(body.generatedAt)
|
||||
? at - body.generatedAt
|
||||
: 0;
|
||||
const records = (Array.isArray(body.records) ? body.records : []).map(
|
||||
(record) => ({
|
||||
...record,
|
||||
lastPositionAt: shiftTime(record.lastPositionAt, offset),
|
||||
lastMessageAt: shiftTime(record.lastMessageAt, offset),
|
||||
}),
|
||||
);
|
||||
emit({
|
||||
configured: true,
|
||||
feeds: Array.isArray(body.feeds) ? body.feeds : [],
|
||||
records: retain(records, at),
|
||||
error: null,
|
||||
updatedAt: at,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function fail() {
|
||||
// A route that never answered means there is nothing to poll; a route
|
||||
// that answered before is reported unreachable and polled again.
|
||||
if (state.configured !== true) {
|
||||
emit({ configured: false, polling: false, error: 'unavailable' });
|
||||
return false;
|
||||
}
|
||||
emit({
|
||||
feeds: state.feeds.map((feed) =>
|
||||
feed.status === 'invalid'
|
||||
? feed
|
||||
: { ...feed, status: 'unreachable', aircraft: 0 },
|
||||
),
|
||||
records: retain([], now()),
|
||||
error: 'unavailable',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tick(current) {
|
||||
timer = null;
|
||||
let keepPolling;
|
||||
try {
|
||||
const body = await request();
|
||||
if (current !== generation) return;
|
||||
keepPolling = apply(body);
|
||||
} catch {
|
||||
if (current !== generation) return;
|
||||
keepPolling = fail();
|
||||
}
|
||||
if (!keepPolling) {
|
||||
generation += 1;
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => void tick(current), intervalMs);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Start polling (no-op while already polling). */
|
||||
start() {
|
||||
if (state.polling) return;
|
||||
generation += 1;
|
||||
emit({ polling: true });
|
||||
void tick(generation);
|
||||
},
|
||||
|
||||
/** Stop polling; the last feed statuses stay readable. */
|
||||
stop() {
|
||||
generation += 1;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
abortInFlight();
|
||||
if (state.polling) emit({ polling: false });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the route once whether feeds are configured, without polling.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
probe() {
|
||||
if (state.polling || state.configured !== null) return Promise.resolve();
|
||||
if (!probing) {
|
||||
const current = generation;
|
||||
probing = request()
|
||||
.then((body) => {
|
||||
if (current === generation && !state.polling) apply(body);
|
||||
})
|
||||
.catch(() => {
|
||||
if (current === generation && !state.polling)
|
||||
emit({ configured: false, error: 'unavailable' });
|
||||
})
|
||||
.finally(() => {
|
||||
probing = null;
|
||||
});
|
||||
}
|
||||
return probing;
|
||||
},
|
||||
|
||||
getState: () => state,
|
||||
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
listeners.clear();
|
||||
retained.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function route(bodies) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
const next = bodies.length > 1 ? bodies.shift() : bodies[0];
|
||||
if (next instanceof Error) throw next;
|
||||
if (next === 404) return { ok: false, status: 404 };
|
||||
return { ok: true, json: async () => structuredClone(next) };
|
||||
};
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
function payload(records, generatedAt = 1_000_000) {
|
||||
return {
|
||||
configured: true,
|
||||
generatedAt,
|
||||
feeds: [
|
||||
{ band: '1090', label: '1090 MHz', status: 'live', aircraft: 1 },
|
||||
{ band: '978', label: '978 MHz UAT', status: 'live', aircraft: 1 },
|
||||
],
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function aircraft(overrides = {}) {
|
||||
return {
|
||||
icao: 'ae5d8a',
|
||||
lat: 30.27,
|
||||
lon: -97.79,
|
||||
lastPositionAt: 999_000,
|
||||
lastMessageAt: 999_500,
|
||||
band: '1090',
|
||||
source: 'feed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('an unconfigured server is asked once and never polled', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([
|
||||
{ configured: false, feeds: [], records: [] },
|
||||
]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl });
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.deepEqual(calls, [LOCAL_RECEIVERS_URL]);
|
||||
assert.equal(feeds.getState().configured, false);
|
||||
assert.equal(feeds.getState().polling, false);
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a build without the route stops after one failed request', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([404]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl });
|
||||
feeds.start();
|
||||
await flush();
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(feeds.getState().configured, false);
|
||||
});
|
||||
|
||||
test('configured feeds poll every second between start and stop, on the browser clock', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([payload([aircraft()])]);
|
||||
const clock = 1_000_250;
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
|
||||
const seen = [];
|
||||
feeds.subscribe((state) => seen.push(state.polling));
|
||||
feeds.start();
|
||||
await flush();
|
||||
const state = feeds.getState();
|
||||
assert.equal(state.configured, true);
|
||||
assert.equal(state.polling, true);
|
||||
assert.equal(state.feeds.length, 2);
|
||||
// Server generated at 1_000_000; received at 1_000_250: rebased +250 ms.
|
||||
assert.equal(state.records[0].lastPositionAt, 999_250);
|
||||
assert.equal(state.records[0].lastMessageAt, 999_750);
|
||||
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3);
|
||||
feeds.stop();
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3, 'no polling once stopped');
|
||||
assert.equal(feeds.getState().polling, false);
|
||||
assert.equal(seen.at(-1), false);
|
||||
});
|
||||
|
||||
test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const bodies = [
|
||||
payload([aircraft(), aircraft({ band: '978' })], 1_000_000),
|
||||
payload([aircraft()], 1_010_000),
|
||||
payload([], 1_060_000),
|
||||
];
|
||||
let clock = 1_000_000;
|
||||
const { fetchImpl } = route(bodies);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.equal(feeds.getState().records.length, 2);
|
||||
clock = 1_010_000;
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
feeds.getState().records.map((record) => record.band),
|
||||
['1090', '978'],
|
||||
'the 978 reception is remembered',
|
||||
);
|
||||
clock = 1_060_000;
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.deepEqual(feeds.getState().records, [], 'forgotten after 60 s');
|
||||
feeds.stop();
|
||||
});
|
||||
|
||||
test('a route that answered before is reported unreachable and polled again', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([
|
||||
payload([aircraft()]),
|
||||
new TypeError('network down'),
|
||||
payload([aircraft()]),
|
||||
]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
feeds.start();
|
||||
await flush();
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
feeds.getState().feeds.map((feed) => feed.status),
|
||||
['unreachable', 'unreachable'],
|
||||
);
|
||||
assert.equal(feeds.getState().records.length, 1, 'recent records remain');
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(feeds.getState().feeds[0].status, 'live');
|
||||
feeds.stop();
|
||||
});
|
||||
|
||||
test('probe asks once without polling so the Radio card can list feeds', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([payload([aircraft()])]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
await Promise.all([feeds.probe(), feeds.probe()]);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(feeds.getState().configured, true);
|
||||
assert.equal(feeds.getState().polling, false);
|
||||
await feeds.probe();
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('two feeds on one band keep the fresher record, whichever is listed last', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const fresh = aircraft({ lastPositionAt: 999_000, lastMessageAt: 999_500 });
|
||||
// A second 1090 feed that heard the aircraft earlier and has no position.
|
||||
const older = aircraft({
|
||||
lat: null,
|
||||
lon: null,
|
||||
lastPositionAt: null,
|
||||
lastMessageAt: 990_000,
|
||||
});
|
||||
const { fetchImpl } = route([
|
||||
payload([fresh, older], 1_000_000),
|
||||
payload([older], 1_000_000),
|
||||
]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
feeds.start();
|
||||
await flush();
|
||||
let [kept] = feeds.getState().records;
|
||||
assert.equal(feeds.getState().records.length, 1);
|
||||
assert.equal(kept.lastPositionAt, 999_000, 'same response');
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
[kept] = feeds.getState().records;
|
||||
assert.equal(kept.lastPositionAt, 999_000, 'next poll');
|
||||
assert.equal(kept.lat, 30.27);
|
||||
feeds.stop();
|
||||
});
|
||||
|
||||
/** A route whose requests never answer until their signal aborts. */
|
||||
function stalledRoute({ stallBody = false } = {}) {
|
||||
const signals = [];
|
||||
const hang = (signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
});
|
||||
const fetchImpl = async (url, options = {}) => {
|
||||
signals.push(options.signal);
|
||||
if (!stallBody) return hang(options.signal);
|
||||
return { ok: true, json: () => hang(options.signal) };
|
||||
};
|
||||
return { signals, fetchImpl };
|
||||
}
|
||||
|
||||
test('a stalled feed request is abandoned at its deadline and polling continues', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
for (const stallBody of [false, true]) {
|
||||
const { signals, fetchImpl } = stalledRoute({ stallBody });
|
||||
const feeds = createLocalReceiverFeeds({
|
||||
fetchImpl,
|
||||
now: () => 1_000_000,
|
||||
requestTimeoutMs: 3_000,
|
||||
});
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.equal(signals.length, 1);
|
||||
assert.ok(signals[0], 'every request carries an abort signal');
|
||||
t.mock.timers.tick(3_000);
|
||||
await flush();
|
||||
assert.equal(signals[0].aborted, true, `aborted (stallBody ${stallBody})`);
|
||||
assert.equal(feeds.getState().configured, false, 'never answered');
|
||||
feeds.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('stop() aborts the request in flight so start() is never blocked by it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { signals, fetchImpl } = stalledRoute();
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
feeds.start();
|
||||
await flush();
|
||||
feeds.stop();
|
||||
assert.equal(signals[0].aborted, true);
|
||||
feeds.start();
|
||||
await flush();
|
||||
feeds.stop();
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.equal(signals.length, 3);
|
||||
assert.deepEqual(
|
||||
signals.map((signal) => signal.aborted),
|
||||
[true, true, false],
|
||||
'no disable/enable cycle leaves an old request outstanding',
|
||||
);
|
||||
feeds.destroy();
|
||||
assert.equal(signals[2].aborted, true);
|
||||
});
|
||||
@@ -0,0 +1,846 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
stabilizeScreenRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
||||
import { routePlausible } from '../../data/routePlausible.js';
|
||||
import {
|
||||
localAdsbPositionIsFresh,
|
||||
mergeLocalAdsbRecords,
|
||||
summarizeLocalAdsb,
|
||||
} from '../../sources/adsbRecords.js';
|
||||
import { GROUND_SCALE } from '../flights/policy.js';
|
||||
import {
|
||||
localAdsbCardModel,
|
||||
localAdsbIsUat,
|
||||
localAdsbSourceText,
|
||||
localAdsbTitle,
|
||||
} from './card.js';
|
||||
import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js';
|
||||
import { createLocalAdsbModels } from './models.js';
|
||||
import { LocalAdsbMotion } from './motion.js';
|
||||
import {
|
||||
ENTITY_PREFIX,
|
||||
LAYER_ID,
|
||||
LAYER_NAME,
|
||||
LAYER_SOURCE,
|
||||
LOCAL_ADSB_COLOR,
|
||||
LOCAL_ADSB_SYNC_MS,
|
||||
LOCAL_ADSB_TICK_MS,
|
||||
LOCAL_ADSB_UAT_RING_COLOR,
|
||||
} from './policy.js';
|
||||
import { localAdsbStatus } from './status.js';
|
||||
|
||||
export {
|
||||
LAYER_ID as LOCAL_ADSB_LAYER_ID,
|
||||
HEARD_BY_RECEIVER,
|
||||
} from './policy.js';
|
||||
export {
|
||||
localAdsbCardModel,
|
||||
localAdsbClassLine,
|
||||
localAdsbIsUat,
|
||||
localAdsbReceiverLine,
|
||||
localAdsbTitle,
|
||||
} from './card.js';
|
||||
export { localAdsbClass } from './enrichment.js';
|
||||
export { localAdsbStatus } from './status.js';
|
||||
|
||||
const FEET_TO_METERS = 0.3048;
|
||||
const RENDER_HOLD_ID = 'local-adsb';
|
||||
/** Picks looked through under a click for a local aircraft. */
|
||||
const CLICK_DRILL_LIMIT = 8;
|
||||
/** Trail lines pass through their aircraft; a click never stops on one. */
|
||||
const TRAIL_PICK_PREFIX = 'gev-trail:';
|
||||
/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */
|
||||
const DEFAULT_DISPLAY = Object.freeze({
|
||||
models3d: true,
|
||||
models3dMode: 'proximity',
|
||||
});
|
||||
|
||||
/**
|
||||
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
|
||||
*
|
||||
* Two inputs feed it. The receiver is any object with `getState()` returning
|
||||
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
|
||||
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
|
||||
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
|
||||
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
|
||||
* by the server from local 1090/978 MHz decoders; it polls only while this
|
||||
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
|
||||
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
|
||||
*
|
||||
* Aircraft get the public Flights treatment in magenta: a class from the
|
||||
* emitter category or adsbdb type (`aircraftClass.js`), the class silhouette
|
||||
* and scale, the Flights 3D models under the DISPLAY rail's 3D toggle, smooth
|
||||
* real-time motion (`./motion.js`, no display delay) and, while selected, a
|
||||
* trail of the positions the receiver heard.
|
||||
*
|
||||
* Markers are never followed by the camera. A marker disappears once its
|
||||
* position is older than 60 s; a record without any message for 60 s is gone.
|
||||
* @param {object} options
|
||||
* @param {object} options.receiver Local receiver session.
|
||||
* @param {object} [options.feeds] Decoder-feed session (start, stop,
|
||||
* getState, subscribe).
|
||||
* @param {object} options.services Render, context, picking, detection and
|
||||
* readout operations supplied by the application; optionally `display`
|
||||
* (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source
|
||||
* with `getEnrichment`), `trails` (`createTrail`), `geoid`
|
||||
* (`ensureGeoidReady`, `geoidHeight`) and `groundSnap`
|
||||
* (`createGroundSnap`).
|
||||
* @param {() => number} [options.now] Clock, injectable for tests.
|
||||
* @param {(url: string) => string} [options.resolveAsset] Model URL resolver.
|
||||
* @param {(options: object) => Promise<object>} [options.loadModel] Model
|
||||
* loader, injectable for tests.
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler]
|
||||
* Click-handler factory, injectable for tests.
|
||||
* @returns {object} Data-layer module.
|
||||
*/
|
||||
export function createLocalAdsbLayer({
|
||||
receiver,
|
||||
feeds = null,
|
||||
services,
|
||||
now = Date.now,
|
||||
resolveAsset = (url) => url,
|
||||
loadModel,
|
||||
createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
}) {
|
||||
if (typeof receiver?.getState !== 'function')
|
||||
throw new TypeError('Local ADS-B requires a receiver session');
|
||||
const {
|
||||
governorRequestRender,
|
||||
holdContinuousRender = () => {},
|
||||
releaseContinuousRender = () => {},
|
||||
} = services.render;
|
||||
const {
|
||||
registerEntityContext,
|
||||
selectEntityContext,
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
} = services.context;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR);
|
||||
const uatRingColor = Cesium.Color.fromCssColorString(
|
||||
LOCAL_ADSB_UAT_RING_COLOR,
|
||||
);
|
||||
|
||||
let viewer = null;
|
||||
let dataSource = null;
|
||||
let enabled = false;
|
||||
let unsubscribe = null;
|
||||
let unsubscribeFeeds = null;
|
||||
let tickTimer = null;
|
||||
let syncTimer = null;
|
||||
let clickHandler = null;
|
||||
let removePreRender = null;
|
||||
let renderHeld = false;
|
||||
let selectedId = null;
|
||||
let selectedTrail = null;
|
||||
let trailHeadSeq = 0;
|
||||
let models = null;
|
||||
let geoidReady = false;
|
||||
let rejectedFixes = 0;
|
||||
let state = receiver.getState();
|
||||
const markers = new Map();
|
||||
// Per-ICAO receptions (band|source -> last message) across merges.
|
||||
const heardBy = new Map();
|
||||
const enrichment = createLocalAdsbEnrichment({
|
||||
source: services.enrichment || null,
|
||||
onChange: () => {
|
||||
for (const marker of markers.values()) refreshClass(marker);
|
||||
refreshSelectedCard();
|
||||
governorRequestRender('local-adsb-enrichment');
|
||||
},
|
||||
});
|
||||
|
||||
function displayPreferences() {
|
||||
return services.display?.getParams?.() || DEFAULT_DISPLAY;
|
||||
}
|
||||
|
||||
function markSourcesChanged(reason) {
|
||||
services.detection?.markSourcesChanged?.(reason);
|
||||
}
|
||||
|
||||
function mergedRecords(at = now(), receiverState = state) {
|
||||
return mergeLocalAdsbRecords(
|
||||
[receiverState.aircraft || [], feeds?.getState?.()?.records || []],
|
||||
at,
|
||||
heardBy,
|
||||
);
|
||||
}
|
||||
|
||||
function freshRecords(at = now()) {
|
||||
return mergedRecords(at).filter((record) =>
|
||||
localAdsbPositionIsFresh(record, at),
|
||||
);
|
||||
}
|
||||
|
||||
function markerVisible(marker) {
|
||||
if (!viewer?.camera?.positionWC || !marker.position) return true;
|
||||
return horizonOccluder(viewer.camera).isPointVisible(marker.position);
|
||||
}
|
||||
|
||||
function markerRotation(marker) {
|
||||
if (!viewer?.scene || !marker.position) return marker.lastRotation;
|
||||
const projected = screenProjectedRotation(
|
||||
viewer.scene,
|
||||
marker.position,
|
||||
marker.courseDeg ?? 0,
|
||||
marker.lastRotation,
|
||||
);
|
||||
const stable = stabilizeScreenRotation(marker.lastRotation, projected);
|
||||
if (stable !== null) marker.lastRotation = stable;
|
||||
return marker.lastRotation;
|
||||
}
|
||||
|
||||
function geoidN(lat, lon) {
|
||||
if (!geoidReady || typeof services.geoid?.geoidHeight !== 'function')
|
||||
return null;
|
||||
try {
|
||||
const value = services.geoid.geoidHeight(lat, lon);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Barometric feet → ellipsoidal render metres, as the Flights layer does
|
||||
* for a contact without a geometric altitude: baro + geoid N. */
|
||||
function renderHeightM(marker, altitudeFt, lat, lon) {
|
||||
if (marker.geoidN === null) marker.geoidN = geoidN(lat, lon);
|
||||
const height = pickRenderAltitudeM({
|
||||
geoAltM: null,
|
||||
baroAltM: Number.isFinite(altitudeFt)
|
||||
? altitudeFt * FEET_TO_METERS
|
||||
: null,
|
||||
onGround: false,
|
||||
surfaceM: null,
|
||||
geoidN: marker.geoidN,
|
||||
});
|
||||
return Number.isFinite(height) ? height : (marker.geoidN ?? 0);
|
||||
}
|
||||
|
||||
function fixPosition(marker, fix) {
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
fix.lon,
|
||||
fix.lat,
|
||||
renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon),
|
||||
);
|
||||
}
|
||||
|
||||
function updateDisplay(marker, at) {
|
||||
const display = marker.motion.displayAt(at);
|
||||
if (!display) return;
|
||||
marker.lat = display.lat;
|
||||
marker.lon = display.lon;
|
||||
marker.position = Cesium.Cartesian3.fromDegrees(
|
||||
display.lon,
|
||||
display.lat,
|
||||
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
marker.position || new Cesium.Cartesian3(),
|
||||
);
|
||||
if (display.courseDeg !== null) marker.courseDeg = display.courseDeg;
|
||||
}
|
||||
|
||||
function billboardScale(marker) {
|
||||
return (
|
||||
(CLASS_SCALE_2D[marker.klass] || 1) * (marker.onGround ? GROUND_SCALE : 1)
|
||||
);
|
||||
}
|
||||
|
||||
/** Class silhouette and per-class scale (×0.8 on the ground), magenta. */
|
||||
function refreshClass(marker) {
|
||||
const { klass, evidence } = localAdsbClass(
|
||||
marker.record,
|
||||
enrichment.get(marker.record.icao),
|
||||
);
|
||||
marker.evidence = evidence;
|
||||
marker.klass = klass;
|
||||
const billboard = marker.entity?.billboard;
|
||||
if (!billboard) return;
|
||||
if (marker.iconKind !== klass) {
|
||||
marker.iconKind = klass;
|
||||
billboard.image = aircraftIcon(klass);
|
||||
}
|
||||
const scale = billboardScale(marker);
|
||||
if (marker.iconScale !== scale) {
|
||||
marker.iconScale = scale;
|
||||
billboard.scale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
function plausibleRoute(marker, meta) {
|
||||
const route = meta?.route;
|
||||
if (!route || route.callsign !== marker.record.callsign) return null;
|
||||
const record = marker.record;
|
||||
const ok = routePlausible({
|
||||
latDeg: marker.lat ?? record.lat,
|
||||
lonDeg: marker.lon ?? record.lon,
|
||||
altitudeM: Number.isFinite(record.altitudeFt)
|
||||
? record.altitudeFt * FEET_TO_METERS
|
||||
: null,
|
||||
verticalRateMps: Number.isFinite(record.verticalRateFpm)
|
||||
? (record.verticalRateFpm * FEET_TO_METERS) / 60
|
||||
: null,
|
||||
origin: route.origin,
|
||||
destination: route.destination,
|
||||
});
|
||||
return ok ? route : null;
|
||||
}
|
||||
|
||||
function cardModel(marker, at) {
|
||||
const meta = enrichment.get(marker.record.icao);
|
||||
return localAdsbCardModel(marker.record, at, {
|
||||
aircraftClass: { klass: marker.klass, evidence: marker.evidence },
|
||||
meta,
|
||||
route: plausibleRoute(marker, meta),
|
||||
});
|
||||
}
|
||||
|
||||
function contextMetadata(id, marker) {
|
||||
const record = marker.record;
|
||||
const meta = enrichment.get(record.icao);
|
||||
return {
|
||||
id,
|
||||
layerId: LAYER_ID,
|
||||
dataSource,
|
||||
layerName: LAYER_NAME,
|
||||
source: localAdsbSourceText(record),
|
||||
label: localAdsbTitle(record),
|
||||
latitude: marker.lat ?? record.lat,
|
||||
longitude: marker.lon ?? record.lon,
|
||||
properties: {
|
||||
icao: record.icao,
|
||||
callsign: record.callsign,
|
||||
category: record.category ?? null,
|
||||
aircraftClass: marker.evidence ? marker.klass : null,
|
||||
typeCode: meta?.typeCode ?? null,
|
||||
altitudeFt: record.altitudeFt,
|
||||
groundSpeedKt: record.groundSpeedKt,
|
||||
trackDeg: record.trackDeg,
|
||||
verticalRateFpm: record.verticalRateFpm,
|
||||
lastPositionAt: record.lastPositionAt,
|
||||
messageCount: record.messageCount,
|
||||
receiverSource: record.source,
|
||||
bands: record.bands,
|
||||
sources: record.sources,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uatRing(marker) {
|
||||
return new Cesium.PointGraphics({
|
||||
pixelSize: 24,
|
||||
color: Cesium.Color.TRANSPARENT,
|
||||
outlineColor: uatRingColor,
|
||||
outlineWidth: 1.5,
|
||||
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
|
||||
show: new Cesium.CallbackProperty(() => markerVisible(marker), false),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
2_000_000,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Trail of the selected aircraft ──────────────────────────────────────
|
||||
|
||||
function releaseTrail() {
|
||||
if (!selectedTrail) return;
|
||||
selectedTrail.trail?.destroy();
|
||||
if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) {
|
||||
try {
|
||||
viewer.entities.remove(selectedTrail.head);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
selectedTrail = null;
|
||||
}
|
||||
|
||||
function refreshTrail() {
|
||||
const marker = selectedId ? markers.get(selectedId) : null;
|
||||
if (!marker || !selectedTrail?.trail) return;
|
||||
const positions = marker.motion.fixes.map((fix) =>
|
||||
fixPosition(marker, fix),
|
||||
);
|
||||
selectedTrail.lastFix = positions.at(-1) || null;
|
||||
selectedTrail.trail.setPositions(positions);
|
||||
}
|
||||
|
||||
function startTrail(marker) {
|
||||
releaseTrail();
|
||||
const createTrail = services.trails?.createTrail;
|
||||
if (typeof createTrail !== 'function' || !viewer?.entities) return;
|
||||
selectedTrail = {
|
||||
id: marker.id,
|
||||
trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }),
|
||||
head: null,
|
||||
lastFix: null,
|
||||
};
|
||||
// Live head: last heard fix → the extrapolated marker, every frame.
|
||||
selectedTrail.head = viewer.entities.add({
|
||||
// 'gev-trail' namespace: claimed by the trail pick owner so a click on
|
||||
// the head segment never reads as empty space.
|
||||
id: `gev-trail:local-adsb-head-${++trailHeadSeq}`,
|
||||
polyline: {
|
||||
positions: new Cesium.CallbackProperty(() => {
|
||||
const current = markers.get(selectedTrail?.id);
|
||||
const from = selectedTrail?.lastFix;
|
||||
if (!current?.position || !from) return [];
|
||||
return [from, Cesium.Cartesian3.clone(current.position)];
|
||||
}, false),
|
||||
width: 2.5,
|
||||
material: color.withAlpha(0.9),
|
||||
depthFailMaterial: color.withAlpha(0.45),
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
refreshTrail();
|
||||
}
|
||||
|
||||
// ── Markers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function upsertMarker(record, at) {
|
||||
const id = `${ENTITY_PREFIX}${record.icao}`;
|
||||
let marker = markers.get(id);
|
||||
if (!marker) {
|
||||
marker = {
|
||||
id,
|
||||
entity: null,
|
||||
record,
|
||||
motion: new LocalAdsbMotion(),
|
||||
position: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null,
|
||||
lastRotation: 0,
|
||||
klass: null,
|
||||
evidence: false,
|
||||
iconKind: null,
|
||||
iconScale: null,
|
||||
onGround: Boolean(record.onGround),
|
||||
geoidN: null,
|
||||
modelOwnsVisual: false,
|
||||
};
|
||||
markers.set(id, marker);
|
||||
}
|
||||
marker.record = record;
|
||||
marker.onGround = Boolean(record.onGround);
|
||||
const rejectedBefore = marker.motion.rejectedFixes;
|
||||
const newFix = marker.motion.observe(record, at);
|
||||
rejectedFixes += marker.motion.rejectedFixes - rejectedBefore;
|
||||
updateDisplay(marker, at);
|
||||
if (!marker.entity) {
|
||||
marker.entity = dataSource.entities.add({
|
||||
id,
|
||||
position: new Cesium.CallbackProperty(() => marker.position, false),
|
||||
billboard: {
|
||||
image: aircraftIcon('airliner'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
scale: 1,
|
||||
color,
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
rotation: new Cesium.CallbackProperty(
|
||||
() => markerRotation(marker),
|
||||
false,
|
||||
),
|
||||
show: new Cesium.CallbackProperty(
|
||||
() => !marker.modelOwnsVisual && markerVisible(marker),
|
||||
false,
|
||||
),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
2_000_000,
|
||||
),
|
||||
},
|
||||
});
|
||||
marker.entity.gevTrackedId = id;
|
||||
marker.entity.gevDisplayPosition = () => marker.position;
|
||||
}
|
||||
refreshClass(marker);
|
||||
const uat = localAdsbIsUat(record);
|
||||
if (uat !== Boolean(marker.entity.point))
|
||||
marker.entity.point = uat ? uatRing(marker) : undefined;
|
||||
marker.entity.gevLabelModel = cardModel(marker, at);
|
||||
registerEntityContext(marker.entity, contextMetadata(id, marker));
|
||||
if (newFix && id === selectedId) refreshTrail();
|
||||
return id;
|
||||
}
|
||||
|
||||
function clearSelection({ evicted = false } = {}) {
|
||||
if (!selectedId) return;
|
||||
selectedId = null;
|
||||
releaseTrail();
|
||||
clearSelectedEntityContextForLayer(LAYER_ID, { evicted });
|
||||
}
|
||||
|
||||
function refreshSelectedCard() {
|
||||
const marker = selectedId ? markers.get(selectedId) : null;
|
||||
if (!marker) return;
|
||||
marker.entity.gevLabelModel = cardModel(marker, now());
|
||||
services.overlays?.refreshReadout?.(marker.entity);
|
||||
}
|
||||
|
||||
function updateRenderHold() {
|
||||
const want = enabled && markers.size > 0;
|
||||
if (want === renderHeld) return;
|
||||
renderHeld = want;
|
||||
if (want) holdContinuousRender(RENDER_HOLD_ID);
|
||||
else releaseContinuousRender(RENDER_HOLD_ID);
|
||||
}
|
||||
|
||||
function requestEnrichment() {
|
||||
const selected = selectedId ? markers.get(selectedId) : null;
|
||||
if (selected) enrichment.request(selected.record, { selected: true });
|
||||
for (const id of models?.eligible || []) {
|
||||
const marker = markers.get(id);
|
||||
if (marker) enrichment.request(marker.record);
|
||||
}
|
||||
}
|
||||
|
||||
function sync() {
|
||||
clearTimeout(syncTimer);
|
||||
syncTimer = null;
|
||||
if (!dataSource) return;
|
||||
const at = now();
|
||||
const live = new Set();
|
||||
let added = false;
|
||||
if (enabled) {
|
||||
for (const record of freshRecords(at)) {
|
||||
const id = `${ENTITY_PREFIX}${record.icao}`;
|
||||
if (!markers.has(id)) added = true;
|
||||
live.add(upsertMarker(record, at));
|
||||
}
|
||||
}
|
||||
let removed = false;
|
||||
for (const [id, marker] of markers) {
|
||||
if (live.has(id)) continue;
|
||||
if (marker.entity) dataSource.entities.remove(marker.entity);
|
||||
markers.delete(id);
|
||||
removed = true;
|
||||
}
|
||||
// Expired aircraft take their 3D models (and pending loads) with them.
|
||||
if (removed) models?.retain(markers);
|
||||
if (selectedId && !live.has(selectedId))
|
||||
clearSelection({ evicted: enabled });
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: live });
|
||||
if (selectedId) {
|
||||
const selected = markers.get(selectedId);
|
||||
if (getSelectedEntityContext()?.id !== selectedId) {
|
||||
selectedId = null;
|
||||
releaseTrail();
|
||||
} else services.overlays?.refreshReadout?.(selected.entity);
|
||||
}
|
||||
requestEnrichment();
|
||||
updateRenderHold();
|
||||
if (live.size || removed) governorRequestRender('local-adsb-update');
|
||||
// Detection re-solves labels only when the set of contacts changes, not
|
||||
// on every position report.
|
||||
if (added || removed) markSourcesChanged('local-adsb-update');
|
||||
}
|
||||
|
||||
/** Per-frame pass: smooth positions and courses, then 3D models. */
|
||||
function frame() {
|
||||
if (!enabled || !markers.size) return;
|
||||
const at = now();
|
||||
for (const marker of markers.values()) updateDisplay(marker, at);
|
||||
models?.frame(markers, displayPreferences(), at);
|
||||
}
|
||||
|
||||
function scheduleSync(nextState) {
|
||||
state = nextState;
|
||||
requestSync();
|
||||
}
|
||||
|
||||
function requestSync() {
|
||||
if (!enabled || syncTimer) return;
|
||||
syncTimer = setTimeout(sync, LOCAL_ADSB_SYNC_MS);
|
||||
}
|
||||
|
||||
function selectMarker(id) {
|
||||
const marker = markers.get(id);
|
||||
if (!marker) return false;
|
||||
const reselect = id === selectedId && selectedTrail?.id === id;
|
||||
selectedId = id;
|
||||
enrichment.request(marker.record, { selected: true });
|
||||
marker.entity.gevLabelModel = cardModel(marker, now());
|
||||
selectEntityContext(marker.entity);
|
||||
if (!reselect) startTrail(marker);
|
||||
governorRequestRender('local-adsb-selection');
|
||||
return true;
|
||||
}
|
||||
|
||||
function pickedId(picked) {
|
||||
const resolved = services.picking.resolvePickId?.(picked);
|
||||
if (resolved !== undefined) return resolved;
|
||||
if (typeof picked?.id === 'string') return picked.id;
|
||||
if (typeof picked?.id?.id === 'string') return picked.id.id;
|
||||
return typeof picked?.primitive?.id === 'string'
|
||||
? picked.primitive.id
|
||||
: null;
|
||||
}
|
||||
|
||||
function ownedElsewhere(id) {
|
||||
return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a click at `position` means for this layer: a local aircraft id,
|
||||
* `'other'` when a sibling layer's contact is under the pointer, `'trail'`
|
||||
* for a trail line, or null for empty map.
|
||||
*
|
||||
* The frontmost pick decides, except that something no layer owns (a
|
||||
* photoreal tile, a ring) or a trail line drawn over or through a local
|
||||
* aircraft does not hide it: the click looks underneath for the aircraft.
|
||||
*/
|
||||
function clickTarget(position) {
|
||||
const scene = viewer.scene;
|
||||
const top = pickedId(scene.pick(position));
|
||||
if (top && markers.has(top)) return top;
|
||||
const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX);
|
||||
if (ownedElsewhere(top) && !trail) return 'other';
|
||||
let hits = [];
|
||||
try {
|
||||
hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || [];
|
||||
} catch {
|
||||
hits = [];
|
||||
}
|
||||
for (const hit of hits) {
|
||||
const id = pickedId(hit);
|
||||
if (id && markers.has(id)) return id;
|
||||
if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue;
|
||||
// A sibling layer's contact above ours takes the click.
|
||||
if (ownedElsewhere(id)) return 'other';
|
||||
}
|
||||
return trail ? 'trail' : null;
|
||||
}
|
||||
|
||||
function installInteraction() {
|
||||
if (clickHandler || !viewer?.scene?.canvas) return;
|
||||
clickHandler = createInputHandler(viewer.scene.canvas);
|
||||
clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree() || !enabled) return;
|
||||
const target = clickTarget(click.position);
|
||||
// Clicking a local aircraft always (re)selects it and republishes its
|
||||
// card, including the one already selected: its card may have been
|
||||
// displaced, and a click on a marker must never read as "deselect".
|
||||
if (target && markers.has(target)) {
|
||||
selectMarker(target);
|
||||
return;
|
||||
}
|
||||
// A click on a trail line is not empty map.
|
||||
if (target === 'trail' || !selectedId) return;
|
||||
// Another contact or empty map releases only this layer's selection,
|
||||
// leaving a sibling layer's new selection intact.
|
||||
clearSelection();
|
||||
governorRequestRender('local-adsb-selection');
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function releaseInteraction() {
|
||||
clickHandler?.destroy();
|
||||
clickHandler = null;
|
||||
}
|
||||
|
||||
function installFrame() {
|
||||
if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return;
|
||||
removePreRender = viewer.scene.preRender.addEventListener(frame);
|
||||
}
|
||||
|
||||
function releaseFrame() {
|
||||
removePreRender?.();
|
||||
removePreRender = null;
|
||||
}
|
||||
|
||||
function positionRows(maxCount = 500) {
|
||||
if (!enabled) return [];
|
||||
const rows = [];
|
||||
for (const marker of markers.values()) {
|
||||
if (rows.length >= maxCount) break;
|
||||
if (!marker.position) continue;
|
||||
const carto = Cesium.Cartographic.fromCartesian(marker.position);
|
||||
rows.push({
|
||||
id: marker.record.icao,
|
||||
label: localAdsbTitle(marker.record),
|
||||
callsign: marker.record.callsign,
|
||||
position: Cesium.Cartesian3.clone(marker.position),
|
||||
latitude: marker.lat,
|
||||
longitude: marker.lon,
|
||||
altitudeM: carto?.height ?? 0,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
return {
|
||||
id: LAYER_ID,
|
||||
name: LAYER_NAME,
|
||||
icon: '📡',
|
||||
source: LAYER_SOURCE,
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: LOCAL_ADSB_TICK_MS,
|
||||
/** The shared receiver session, also driven by the Radio panel card. */
|
||||
receiver,
|
||||
/** Decoder-feed session; the Radio card shows its one-line summary. */
|
||||
feeds,
|
||||
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
dataSource = new Cesium.CustomDataSource(LAYER_ID);
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
if (viewer.scene?.primitives) {
|
||||
models = createLocalAdsbModels({
|
||||
viewer,
|
||||
color,
|
||||
resolveAsset,
|
||||
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
||||
...(loadModel ? { loadModel } : {}),
|
||||
});
|
||||
}
|
||||
services.geoid
|
||||
?.ensureGeoidReady?.()
|
||||
?.then(() => {
|
||||
geoidReady = true;
|
||||
for (const marker of markers.values()) marker.geoidN = null;
|
||||
})
|
||||
?.catch(() => {
|
||||
/* the baro path stays un-geoid-corrected */
|
||||
});
|
||||
unsubscribe = receiver.subscribe?.(scheduleSync) || null;
|
||||
unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null;
|
||||
return true;
|
||||
},
|
||||
|
||||
async enable() {
|
||||
enabled = true;
|
||||
if (dataSource) dataSource.show = true;
|
||||
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
|
||||
installInteraction();
|
||||
installFrame();
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = setInterval(sync, LOCAL_ADSB_TICK_MS);
|
||||
feeds?.start?.();
|
||||
// The layer asks the shared tuner for 1090 MHz. Disabling it leaves the
|
||||
// receiver in whatever mode the Radio card shows, so turning the layer
|
||||
// off never starts FM audio on its own.
|
||||
if (receiver.getState().mode !== 'adsb') await receiver.setMode('adsb');
|
||||
state = receiver.getState();
|
||||
sync();
|
||||
return true;
|
||||
},
|
||||
|
||||
async disable() {
|
||||
enabled = false;
|
||||
feeds?.stop?.();
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
releaseInteraction();
|
||||
releaseFrame();
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
sync();
|
||||
models?.clear();
|
||||
if (dataSource) dataSource.show = false;
|
||||
markSourcesChanged('local-adsb-disabled');
|
||||
return true;
|
||||
},
|
||||
|
||||
async update() {
|
||||
state = receiver.getState();
|
||||
sync();
|
||||
frame();
|
||||
return true;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
enabled = false;
|
||||
clearInterval(tickTimer);
|
||||
clearTimeout(syncTimer);
|
||||
tickTimer = null;
|
||||
syncTimer = null;
|
||||
releaseInteraction();
|
||||
releaseFrame();
|
||||
unregisterPickOwner(LAYER_ID);
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
unsubscribeFeeds?.();
|
||||
unsubscribeFeeds = null;
|
||||
feeds?.destroy?.();
|
||||
enrichment.destroy();
|
||||
clearSelection();
|
||||
removeEntityContextsForLayer(LAYER_ID);
|
||||
markers.clear();
|
||||
updateRenderHold();
|
||||
models?.destroy();
|
||||
models = null;
|
||||
if (viewer && dataSource) viewer.dataSources.remove(dataSource, true);
|
||||
dataSource = null;
|
||||
viewer = null;
|
||||
void receiver.destroy?.();
|
||||
},
|
||||
|
||||
getStats() {
|
||||
const current = receiver.getState();
|
||||
const at = now();
|
||||
const records = mergedRecords(at, current);
|
||||
const { heard, positioned } = summarizeLocalAdsb(records, at);
|
||||
const lastUpdate =
|
||||
records.reduce(
|
||||
(latest, record) => Math.max(latest, record.lastMessageAt || 0),
|
||||
0,
|
||||
) || null;
|
||||
return {
|
||||
count: positioned,
|
||||
lastUpdate,
|
||||
source: LAYER_SOURCE,
|
||||
// Fixes refused by the speed check: the browser decoder's own count
|
||||
// plus the layer's check on every merged record.
|
||||
rejectedPositions:
|
||||
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
|
||||
...localAdsbStatus({
|
||||
receiver: current,
|
||||
feedState: feeds?.getState?.() || null,
|
||||
heard,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a heard aircraft's marker and publish its readout card, exactly
|
||||
* as a click does. The camera does not follow it.
|
||||
* @param {string} icao Lowercase ICAO hex.
|
||||
* @returns {boolean} Whether a fresh marker was selected.
|
||||
*/
|
||||
selectAircraft(icao) {
|
||||
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 500) {
|
||||
return positionRows(maxCount);
|
||||
},
|
||||
|
||||
getDetectableObjects(options = {}) {
|
||||
return positionRows(options.maxCount).map((row) => ({
|
||||
position: row.position,
|
||||
sourceId: row.id,
|
||||
// Label only a decoded callsign; never substitute the raw ICAO hex.
|
||||
id: row.callsign || '',
|
||||
type: 'AIR',
|
||||
skipLabel: false,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,282 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
MODEL_ALL_ADD_M,
|
||||
MODEL_ALL_KEEP_M,
|
||||
MODEL_ALT_CEIL_M,
|
||||
MODEL_HEADING_OFFSET_DEG,
|
||||
MODEL_MAX,
|
||||
MODEL_MAX_ALL,
|
||||
MODEL_MIN_PX,
|
||||
MODEL_PROX_ADD_M,
|
||||
MODEL_PROX_KEEP_M,
|
||||
} from '../flights/policy.js';
|
||||
|
||||
const specKey = (spec) => `${spec.url}@${spec.scale}`;
|
||||
|
||||
/**
|
||||
* 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle.
|
||||
*
|
||||
* Mirrors the public Flights fleet: models only below the altitude ceiling,
|
||||
* the same proximity/all caps and add/keep radii, the same visible-first
|
||||
* eligibility (`selectModelEligible`), the same per-class asset and scale
|
||||
* (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here
|
||||
* magenta, as Flights tints military amber). Each model owns its matrix. A
|
||||
* billboard stays the visual until its model is loaded AND placed; a grounded
|
||||
* aircraft's model rides the one-shot ground snap plus the class belly
|
||||
* offset, and without a resolved ground it stays a billboard.
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {Cesium.Color} options.color Model tint.
|
||||
* @param {(url: string) => string} options.resolveAsset
|
||||
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
||||
* @param {(options: object) => Promise<object>} [options.loadModel]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createLocalAdsbModels({
|
||||
viewer,
|
||||
color,
|
||||
resolveAsset,
|
||||
groundSnap = null,
|
||||
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
||||
}) {
|
||||
const collection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
/** @type {Map<string, object>} id -> admitted model */
|
||||
const models = new Map();
|
||||
/** @type {Map<string, number>} id -> generation of an in-flight load */
|
||||
const pending = new Map();
|
||||
let generation = 0;
|
||||
let destroyed = false;
|
||||
let lastEligibilityAt = 0;
|
||||
let eligible = new Set();
|
||||
const scratchHpr = new Cesium.HeadingPitchRoll();
|
||||
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
|
||||
const scratchCarto = new Cesium.Cartographic();
|
||||
const scratchGround = new Cesium.Cartesian3();
|
||||
|
||||
function release(id) {
|
||||
pending.delete(id);
|
||||
const model = models.get(id);
|
||||
if (!model) return;
|
||||
models.delete(id);
|
||||
if (!collection.isDestroyed()) collection.remove(model);
|
||||
groundSnap?.forget?.(id);
|
||||
}
|
||||
|
||||
function releaseAll() {
|
||||
for (const id of [...models.keys()]) release(id);
|
||||
pending.clear();
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
async function ensure(id, marker, cap) {
|
||||
const spec = civilAircraftModelSpec(marker.klass);
|
||||
const key = specKey(spec);
|
||||
const current = models.get(id);
|
||||
if (current && current._gevSpecKey === key) return;
|
||||
if (current) release(id);
|
||||
if (pending.has(id) || models.size + pending.size >= cap) return;
|
||||
const loadGeneration = generation;
|
||||
pending.set(id, loadGeneration);
|
||||
let model;
|
||||
try {
|
||||
model = await loadModel({
|
||||
url: resolveAsset(spec.url),
|
||||
asynchronous: false,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: spec.blendAmount,
|
||||
id,
|
||||
});
|
||||
} catch {
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
return;
|
||||
}
|
||||
const stale =
|
||||
destroyed ||
|
||||
pending.get(id) !== loadGeneration ||
|
||||
generation !== loadGeneration ||
|
||||
!eligible.has(id) ||
|
||||
models.has(id) ||
|
||||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
if (stale) {
|
||||
model.destroy?.();
|
||||
return;
|
||||
}
|
||||
model.id = id;
|
||||
model._gevSpecKey = key;
|
||||
model._gevBellyM = spec.bellyM;
|
||||
// Admitted, not yet the visual: placement turns it on.
|
||||
model.show = false;
|
||||
collection.add(model);
|
||||
models.set(id, model);
|
||||
}
|
||||
|
||||
function regime(preferences) {
|
||||
if (!preferences?.models3d) return false;
|
||||
const height = viewer.camera?.positionCartographic?.height ?? Infinity;
|
||||
return height < MODEL_ALT_CEIL_M;
|
||||
}
|
||||
|
||||
function refreshEligibility(markers, preferences) {
|
||||
const all = preferences.models3dMode === 'all';
|
||||
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
|
||||
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
|
||||
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
|
||||
const camera = viewer.camera;
|
||||
const cameraPosition = camera.positionWC;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
cameraPosition,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const candidates = [];
|
||||
for (const [id, marker] of markers) {
|
||||
if (!marker.position) continue;
|
||||
const distanceSq = Cesium.Cartesian3.distanceSquared(
|
||||
cameraPosition,
|
||||
marker.position,
|
||||
);
|
||||
if (distanceSq > keepM * keepM) continue;
|
||||
Cesium.Cartesian3.clone(marker.position, scratchSphere.center);
|
||||
candidates.push([
|
||||
id,
|
||||
distanceSq,
|
||||
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
candidates.sort((a, b) => a[1] - b[1]);
|
||||
eligible = selectModelEligible(candidates, {
|
||||
cap,
|
||||
addDistSq: addM * addM,
|
||||
isModeled: (id) => models.has(id),
|
||||
});
|
||||
for (const id of [...models.keys(), ...pending.keys()])
|
||||
if (!eligible.has(id)) release(id);
|
||||
for (const id of eligible) void ensure(id, markers.get(id), cap);
|
||||
}
|
||||
|
||||
function placement(id, marker) {
|
||||
if (!marker.onGround) return marker.position;
|
||||
const model = models.get(id);
|
||||
// The vertical sample must hit the tile skin, not this aircraft's own
|
||||
// billboard or model.
|
||||
const height = groundSnap?.heightFor?.(viewer, id, marker.position, () =>
|
||||
[marker.entity, model].filter(Boolean),
|
||||
);
|
||||
if (!Number.isFinite(height)) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
marker.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scratchCarto,
|
||||
);
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
height + (model?._gevBellyM || 0),
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scratchGround,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Ids whose model admission is current; the layer enriches these. */
|
||||
get eligible() {
|
||||
return eligible;
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-frame pass: re-derive eligibility at the fleet cadence, then place
|
||||
* every admitted model and hand each marker's visual to its model only
|
||||
* once the model is ready and placed.
|
||||
* @param {Map<string, object>} markers Layer markers (position, courseDeg,
|
||||
* klass, onGround); `modelOwnsVisual` is written back.
|
||||
* @param {object} preferences `{ models3d, models3dMode }`.
|
||||
* @param {number} nowMs
|
||||
*/
|
||||
frame(markers, preferences, nowMs) {
|
||||
if (destroyed) return;
|
||||
if (!regime(preferences)) {
|
||||
if (models.size || pending.size) releaseAll();
|
||||
eligible = new Set();
|
||||
for (const marker of markers.values()) marker.modelOwnsVisual = false;
|
||||
return;
|
||||
}
|
||||
if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) {
|
||||
lastEligibilityAt = nowMs;
|
||||
refreshEligibility(markers, preferences);
|
||||
}
|
||||
const occluder = horizonOccluder(viewer.camera);
|
||||
for (const [id, marker] of markers) {
|
||||
const model = models.get(id);
|
||||
marker.modelOwnsVisual = false;
|
||||
if (!model || !marker.position) continue;
|
||||
const position = placement(id, marker);
|
||||
if (!position || !occluder.isPointVisible(marker.position)) {
|
||||
model.show = false;
|
||||
continue;
|
||||
}
|
||||
scratchHpr.heading = Cesium.Math.toRadians(
|
||||
(marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG,
|
||||
);
|
||||
scratchHpr.pitch = 0;
|
||||
scratchHpr.roll = 0;
|
||||
Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
position,
|
||||
scratchHpr,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
undefined,
|
||||
model.modelMatrix,
|
||||
);
|
||||
if (!model.ready) {
|
||||
model.show = false;
|
||||
continue;
|
||||
}
|
||||
model.show = true;
|
||||
marker.modelOwnsVisual = true;
|
||||
}
|
||||
for (const id of [...models.keys()]) if (!markers.has(id)) release(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the model, and any load still in flight, of every aircraft that
|
||||
* is no longer a marker. The layer calls this whenever markers expire:
|
||||
* the per-frame pass does not run once the last marker is gone.
|
||||
* @param {Map<string, object>|Set<string>} live Current marker ids.
|
||||
*/
|
||||
retain(live) {
|
||||
if (destroyed) return;
|
||||
let dropped = false;
|
||||
for (const id of [...models.keys(), ...pending.keys()]) {
|
||||
if (live.has(id)) continue;
|
||||
release(id);
|
||||
eligible.delete(id);
|
||||
dropped = true;
|
||||
}
|
||||
if (dropped) eligible = new Set(eligible);
|
||||
},
|
||||
|
||||
/** Drop every model (3D off, layer disabled). */
|
||||
clear: releaseAll,
|
||||
|
||||
/** Number of admitted models (tests and diagnostics). */
|
||||
get size() {
|
||||
return models.size;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
releaseAll();
|
||||
groundSnap?.clear?.();
|
||||
if (!viewer.isDestroyed?.() && !collection.isDestroyed())
|
||||
viewer.scene.primitives.remove(collection);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
TURN_MIN_SPEED_MPS,
|
||||
courseBetweenCartesians,
|
||||
courseSlewCapDps,
|
||||
estimateTurnRateDps,
|
||||
lerpAngleDeg,
|
||||
limitCourseStep,
|
||||
norm360,
|
||||
projectGroundArcLatLon,
|
||||
speedRamp,
|
||||
staleCoastLimitSeconds,
|
||||
} from '../../data/motionModel.js';
|
||||
import {
|
||||
LOCAL_ADSB_REANCHOR_AFTER,
|
||||
localAdsbFixIsPlausible,
|
||||
} from '../../sources/adsbRecords.js';
|
||||
import {
|
||||
COURSE_MAX_DPS,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
DR_CORRECTION_MS,
|
||||
} from '../flights/policy.js';
|
||||
|
||||
/** Heard positions kept per aircraft for its trail: 10 minutes, 600 fixes. */
|
||||
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
|
||||
export const LOCAL_ADSB_HISTORY_POINTS = 600;
|
||||
/** A display correction larger than this snaps instead of sliding. */
|
||||
export const LOCAL_ADSB_SNAP_M = 3_000;
|
||||
/** Chord course uses the newest fix at least this much older than the anchor. */
|
||||
const CHORD_BASELINE_MS = 2_000;
|
||||
/** Turn rate is estimated over this window of heard fixes. */
|
||||
const TURN_WINDOW_MS = 30_000;
|
||||
/** Coast at least this long past a fix; longer while messages keep coming. */
|
||||
const COAST_MINIMUM_SEC = 10;
|
||||
const COAST_CONTACT_GRACE_SEC = 10;
|
||||
const COAST_MAXIMUM_SEC = 60;
|
||||
const KT_TO_MPS = 1852 / 3600;
|
||||
const EARTH_RADIUS_M = 6_371_008.8;
|
||||
const DEG = Math.PI / 180;
|
||||
const _chordFrom = new Cesium.Cartesian3();
|
||||
const _chordTo = new Cesium.Cartesian3();
|
||||
|
||||
function finite(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a record's telemetry (altitude, speed, track, climb) was last heard:
|
||||
* its newest message, which velocity-only and altitude-only messages advance
|
||||
* without a new position.
|
||||
*/
|
||||
function telemetryTime(record) {
|
||||
const messageAt = finite(record?.lastMessageAt);
|
||||
const positionAt = finite(record?.lastPositionAt);
|
||||
if (messageAt === null) return positionAt;
|
||||
return positionAt === null ? messageAt : Math.max(messageAt, positionAt);
|
||||
}
|
||||
|
||||
function chordCourse(from, to) {
|
||||
Cesium.Cartesian3.fromDegrees(from.lon, from.lat, 0, undefined, _chordFrom);
|
||||
Cesium.Cartesian3.fromDegrees(to.lon, to.lat, 0, undefined, _chordTo);
|
||||
return courseBetweenCartesians(_chordFrom, _chordTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time display motion for one locally heard aircraft.
|
||||
*
|
||||
* Unlike the public flight layers there is no deliberate one-poll display
|
||||
* delay: local fixes arrive about once a second, so the marker is
|
||||
* extrapolated FORWARD from the newest fix with the shared motion model
|
||||
* (constant-rate-turn arc, path-derived course blended over the reported
|
||||
* track by speed, rate-limited slew) and a new fix is absorbed by a
|
||||
* correction offset that decays over the same window the tracked public
|
||||
* flight uses, so the marker never snaps. Every fix also passes the same
|
||||
* speed check the decoder applies, which covers decoder-feed records too.
|
||||
*
|
||||
* Positions are geodetic (degrees, barometric feet); callers add the render
|
||||
* datum. The heard history doubles as the selected aircraft's trail.
|
||||
*/
|
||||
export class LocalAdsbMotion {
|
||||
constructor() {
|
||||
/** @type {Array<{at:number, lat:number, lon:number, altitudeFt:number|null,
|
||||
* trackDeg:number|null, speedMps:number|null}>} */
|
||||
this.fixes = [];
|
||||
this.anchor = null;
|
||||
this.correction = null;
|
||||
this.courseDeg = null;
|
||||
this.lastCourseAt = null;
|
||||
this.lastContactAt = null;
|
||||
this.rejectStreak = 0;
|
||||
this.rejectedFixes = 0;
|
||||
// The newest position evaluated, accepted or refused. Re-reading it (the
|
||||
// layer re-syncs the same record several times a second) is not a new
|
||||
// observation and must not count toward a re-anchor.
|
||||
this.lastCandidate = null;
|
||||
// When the anchor's telemetry was heard. Telemetry has its own freshness:
|
||||
// position freshness only decides trail points and re-anchoring.
|
||||
this.telemetryAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a record's newest position. Returns true when it became the new
|
||||
* anchor; a repeated, out-of-order or implausible fix returns false.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {number} nowMs Current epoch ms (display clock).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
observe(record, nowMs) {
|
||||
if (Number.isFinite(record?.lastMessageAt))
|
||||
this.lastContactAt = Math.max(
|
||||
this.lastContactAt ?? -Infinity,
|
||||
record.lastMessageAt,
|
||||
);
|
||||
const at = finite(record?.lastPositionAt);
|
||||
const lat = finite(record?.lat);
|
||||
const lon = finite(record?.lon);
|
||||
if (at === null || lat === null || lon === null) return false;
|
||||
const newest = this.fixes.at(-1);
|
||||
const seen = this.lastCandidate;
|
||||
// No new position, but velocity and altitude messages arrive between
|
||||
// position messages: newer telemetry still reaches the anchor.
|
||||
if (
|
||||
(newest && at <= newest.at) ||
|
||||
(seen && at <= seen.at) ||
|
||||
// A decoder feed re-reads the same fix every poll and its rebased time
|
||||
// jitters with `seen_pos` rounding: the same position is not a new fix.
|
||||
(seen && !seen.accepted && lat === seen.lat && lon === seen.lon)
|
||||
) {
|
||||
this._refreshTelemetry(record, nowMs);
|
||||
return false;
|
||||
}
|
||||
if (newest && lat === newest.lat && lon === newest.lon) {
|
||||
// Same coordinates, but altitude and velocity may still be newer (a
|
||||
// hovering helicopter climbing, an aircraft coming to a stop): update
|
||||
// the motion telemetry without adding a trail point.
|
||||
this.lastCandidate = { at, lat, lon, accepted: true };
|
||||
this.rejectStreak = 0;
|
||||
this._refreshTelemetry(record, nowMs);
|
||||
return false;
|
||||
}
|
||||
const speedMps = Number.isFinite(record.groundSpeedKt)
|
||||
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
|
||||
: (newest?.speedMps ?? null);
|
||||
const fix = {
|
||||
at,
|
||||
lat,
|
||||
lon,
|
||||
altitudeFt: finite(record.altitudeFt),
|
||||
trackDeg: Number.isFinite(record.trackDeg)
|
||||
? norm360(record.trackDeg)
|
||||
: null,
|
||||
speedMps,
|
||||
};
|
||||
if (
|
||||
newest &&
|
||||
!localAdsbFixIsPlausible(newest, fix, {
|
||||
// The last known speed stands in while a record carries none.
|
||||
groundSpeedKt: Number.isFinite(speedMps) ? speedMps / KT_TO_MPS : null,
|
||||
category: record.category,
|
||||
})
|
||||
) {
|
||||
this.rejectedFixes += 1;
|
||||
this.rejectStreak += 1;
|
||||
this.lastCandidate = { at, lat, lon, accepted: false };
|
||||
if (this.rejectStreak < LOCAL_ADSB_REANCHOR_AFTER) {
|
||||
// The position is refused, not the record's telemetry.
|
||||
this._refreshTelemetry(record, nowMs);
|
||||
return false;
|
||||
}
|
||||
// Three refusals in a row: the stream moved on and the old anchor was
|
||||
// the outlier. Restart the history from the new fix.
|
||||
this.fixes = [];
|
||||
this.correction = null;
|
||||
}
|
||||
this.rejectStreak = 0;
|
||||
this.lastCandidate = { at, lat, lon, accepted: true };
|
||||
|
||||
const prior = this.anchor;
|
||||
const before = prior ? this.displayAt(nowMs, { slew: false }) : null;
|
||||
this.fixes.push(fix);
|
||||
this._trimHistory(at);
|
||||
// Position and telemetry are accepted independently: merged receivers
|
||||
// can deliver a newer position in a record whose last message is OLDER
|
||||
// than the telemetry the anchor already holds. The trail point keeps the
|
||||
// record's values as heard; the anchor keeps the newer telemetry.
|
||||
const recordTelemetryAt = telemetryTime(record);
|
||||
const keepTelemetry =
|
||||
prior &&
|
||||
this.telemetryAt !== null &&
|
||||
(recordTelemetryAt === null || recordTelemetryAt < this.telemetryAt);
|
||||
if (keepTelemetry) {
|
||||
this.anchor = {
|
||||
...this._anchorFrom(
|
||||
{
|
||||
...fix,
|
||||
altitudeFt: prior.altitudeFt,
|
||||
speedMps: prior.speedMps,
|
||||
trackDeg: prior.trackDeg,
|
||||
},
|
||||
{ verticalRateFpm: prior.verticalRateFpm },
|
||||
),
|
||||
altitudeAt: Number.isFinite(prior.altitudeAt)
|
||||
? prior.altitudeAt
|
||||
: prior.at,
|
||||
};
|
||||
} else {
|
||||
this.anchor = this._anchorFrom(fix, record);
|
||||
this.telemetryAt = recordTelemetryAt;
|
||||
}
|
||||
this._absorb(before, nowMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a record's altitude, speed, track and vertical rate to the current
|
||||
* anchor when they were heard after the anchor's telemetry, whether or not
|
||||
* its position is new.
|
||||
*/
|
||||
_refreshTelemetry(record, nowMs) {
|
||||
const anchor = this.anchor;
|
||||
const newest = this.fixes.at(-1);
|
||||
if (!anchor || !newest) return;
|
||||
const at = telemetryTime(record);
|
||||
if (at === null || (this.telemetryAt !== null && at <= this.telemetryAt))
|
||||
return;
|
||||
this.telemetryAt = at;
|
||||
const altitudeFt = finite(record.altitudeFt) ?? anchor.altitudeFt;
|
||||
const speedMps = Number.isFinite(record.groundSpeedKt)
|
||||
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
|
||||
: anchor.speedMps;
|
||||
const trackDeg = Number.isFinite(record.trackDeg)
|
||||
? norm360(record.trackDeg)
|
||||
: anchor.trackDeg;
|
||||
const verticalRateFpm = finite(record.verticalRateFpm);
|
||||
if (
|
||||
altitudeFt === anchor.altitudeFt &&
|
||||
speedMps === anchor.speedMps &&
|
||||
trackDeg === anchor.trackDeg &&
|
||||
verticalRateFpm === anchor.verticalRateFpm
|
||||
)
|
||||
return;
|
||||
const before = this.displayAt(nowMs, { slew: false });
|
||||
// The trail point keeps its heard position; the anchor takes the newer
|
||||
// telemetry, with altitude extrapolated from when it was reported.
|
||||
this.anchor = {
|
||||
...this._anchorFrom(
|
||||
{ ...newest, altitudeFt, speedMps, trackDeg },
|
||||
{ verticalRateFpm },
|
||||
),
|
||||
altitudeAt: at,
|
||||
};
|
||||
this._absorb(before, nowMs);
|
||||
}
|
||||
|
||||
/** Slide from where the marker was drawn to the new anchor, or snap. */
|
||||
_absorb(before, nowMs) {
|
||||
if (before) {
|
||||
const after = this._raw(nowMs);
|
||||
const cosLat = Math.cos(after.lat * DEG);
|
||||
const north = (before.lat - after.lat) * DEG * EARTH_RADIUS_M;
|
||||
const east = (before.lon - after.lon) * DEG * EARTH_RADIUS_M * cosLat;
|
||||
const up =
|
||||
Number.isFinite(before.altitudeFt) && Number.isFinite(after.altitudeFt)
|
||||
? before.altitudeFt - after.altitudeFt
|
||||
: 0;
|
||||
this.correction =
|
||||
Math.hypot(north, east) > LOCAL_ADSB_SNAP_M
|
||||
? null
|
||||
: { east, north, upFt: up, startAt: nowMs };
|
||||
}
|
||||
}
|
||||
|
||||
_trimHistory(newestAt) {
|
||||
let drop = 0;
|
||||
while (
|
||||
drop < this.fixes.length - 1 &&
|
||||
(newestAt - this.fixes[drop].at > LOCAL_ADSB_HISTORY_MS ||
|
||||
this.fixes.length - drop > LOCAL_ADSB_HISTORY_POINTS)
|
||||
)
|
||||
drop += 1;
|
||||
if (drop) this.fixes.splice(0, drop);
|
||||
}
|
||||
|
||||
_anchorFrom(fix, record) {
|
||||
let chord = null;
|
||||
for (let index = this.fixes.length - 2; index >= 0; index -= 1) {
|
||||
if (fix.at - this.fixes[index].at < CHORD_BASELINE_MS) continue;
|
||||
chord = chordCourse(this.fixes[index], fix);
|
||||
break;
|
||||
}
|
||||
let courseDeg = fix.trackDeg;
|
||||
if (chord !== null)
|
||||
courseDeg =
|
||||
courseDeg === null
|
||||
? chord
|
||||
: lerpAngleDeg(courseDeg, chord, speedRamp(fix.speedMps));
|
||||
const samples = [];
|
||||
let lastSampleAt = -Infinity;
|
||||
for (const sample of this.fixes) {
|
||||
if (fix.at - sample.at > TURN_WINDOW_MS) continue;
|
||||
if (sample.at - lastSampleAt < CHORD_BASELINE_MS) continue;
|
||||
lastSampleAt = sample.at;
|
||||
samples.push({
|
||||
tSec: sample.at / 1000,
|
||||
trackDeg: sample.trackDeg,
|
||||
speedMps: sample.speedMps,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...fix,
|
||||
courseDeg,
|
||||
turnRateDps: estimateTurnRateDps(
|
||||
samples,
|
||||
undefined,
|
||||
undefined,
|
||||
TURN_MIN_SPEED_MPS,
|
||||
),
|
||||
verticalRateFpm: finite(record.verticalRateFpm),
|
||||
};
|
||||
}
|
||||
|
||||
_coastSeconds(nowMs) {
|
||||
const anchor = this.anchor;
|
||||
const limit = staleCoastLimitSeconds({
|
||||
fixEpochMs: anchor.at,
|
||||
lastContactEpochMs: this.lastContactAt,
|
||||
minimumSec: COAST_MINIMUM_SEC,
|
||||
contactGraceSec: COAST_CONTACT_GRACE_SEC,
|
||||
maximumSec: COAST_MAXIMUM_SEC,
|
||||
});
|
||||
return Math.min(limit, Math.max(0, (nowMs - anchor.at) / 1000));
|
||||
}
|
||||
|
||||
_raw(nowMs) {
|
||||
const anchor = this.anchor;
|
||||
const dtSec = this._coastSeconds(nowMs);
|
||||
const moving =
|
||||
Number.isFinite(anchor.speedMps) &&
|
||||
anchor.speedMps >= COURSE_HOLD_SPEED_MPS &&
|
||||
Number.isFinite(anchor.courseDeg);
|
||||
const projected = moving
|
||||
? projectGroundArcLatLon(
|
||||
anchor.lat,
|
||||
anchor.lon,
|
||||
anchor.courseDeg,
|
||||
anchor.speedMps,
|
||||
anchor.turnRateDps,
|
||||
dtSec,
|
||||
)
|
||||
: { lat: anchor.lat, lon: anchor.lon };
|
||||
const altitudeDtSec = Number.isFinite(anchor.altitudeAt)
|
||||
? Math.min(dtSec, Math.max(0, (nowMs - anchor.altitudeAt) / 1000))
|
||||
: dtSec;
|
||||
const altitudeFt =
|
||||
Number.isFinite(anchor.altitudeFt) &&
|
||||
Number.isFinite(anchor.verticalRateFpm)
|
||||
? anchor.altitudeFt + (anchor.verticalRateFpm * altitudeDtSec) / 60
|
||||
: anchor.altitudeFt;
|
||||
return {
|
||||
lat: projected.lat,
|
||||
lon: projected.lon,
|
||||
altitudeFt,
|
||||
courseDeg: moving
|
||||
? norm360(anchor.courseDeg + anchor.turnRateDps * dtSec)
|
||||
: null,
|
||||
speedMps: anchor.speedMps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to draw the aircraft now.
|
||||
* @param {number} nowMs Current epoch ms.
|
||||
* @param {{slew?: boolean}} [options] `slew: false` reads without advancing
|
||||
* the rate-limited course.
|
||||
* @returns {{lat:number, lon:number, altitudeFt:number|null,
|
||||
* courseDeg:number|null}|null}
|
||||
*/
|
||||
displayAt(nowMs, { slew = true } = {}) {
|
||||
if (!this.anchor) return null;
|
||||
const raw = this._raw(nowMs);
|
||||
const correction = this.correction;
|
||||
if (correction) {
|
||||
const weight = 1 - (nowMs - correction.startAt) / DR_CORRECTION_MS;
|
||||
if (weight <= 0) this.correction = null;
|
||||
else {
|
||||
const w = Math.min(1, weight);
|
||||
raw.lat += (correction.north * w) / EARTH_RADIUS_M / DEG;
|
||||
raw.lon +=
|
||||
(correction.east * w) /
|
||||
(EARTH_RADIUS_M * Math.max(1e-6, Math.cos(raw.lat * DEG))) /
|
||||
DEG;
|
||||
if (Number.isFinite(raw.altitudeFt))
|
||||
raw.altitudeFt += correction.upFt * w;
|
||||
}
|
||||
}
|
||||
let courseDeg = this.courseDeg;
|
||||
// Hover and taxi noise: below the hold speed the course is not updated.
|
||||
const target =
|
||||
raw.courseDeg ?? (courseDeg === null ? this.anchor.trackDeg : null);
|
||||
if (slew && target !== null) {
|
||||
const dtSec =
|
||||
this.lastCourseAt === null
|
||||
? 0
|
||||
: Math.min(
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
Math.max(0, (nowMs - this.lastCourseAt) / 1000),
|
||||
);
|
||||
courseDeg = limitCourseStep(
|
||||
courseDeg,
|
||||
target,
|
||||
courseSlewCapDps(raw.speedMps, COURSE_MAX_DPS),
|
||||
dtSec,
|
||||
);
|
||||
this.courseDeg = courseDeg;
|
||||
this.lastCourseAt = nowMs;
|
||||
} else if (courseDeg === null && target !== null) {
|
||||
courseDeg = target;
|
||||
}
|
||||
return {
|
||||
lat: raw.lat,
|
||||
lon: raw.lon,
|
||||
altitudeFt: raw.altitudeFt,
|
||||
courseDeg,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
LOCAL_ADSB_HISTORY_MS,
|
||||
LOCAL_ADSB_HISTORY_POINTS,
|
||||
LocalAdsbMotion,
|
||||
} from './motion.js';
|
||||
|
||||
function fix(at, lat, overrides = {}) {
|
||||
return {
|
||||
icao: 'abc123',
|
||||
lat,
|
||||
lon: 0,
|
||||
altitudeFt: 5_000,
|
||||
groundSpeedKt: 120,
|
||||
trackDeg: 0,
|
||||
verticalRateFpm: 0,
|
||||
lastPositionAt: at,
|
||||
lastMessageAt: at,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 120 kt north: degrees of latitude per second.
|
||||
const STEP = (120 * 1852) / 3600 / 111_195;
|
||||
|
||||
test('the heard history is bounded to 600 fixes and 10 minutes', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
for (let second = 0; second < 700; second += 1)
|
||||
motion.observe(fix(second * 1_000, second * STEP), second * 1_000);
|
||||
assert.equal(motion.fixes.length, LOCAL_ADSB_HISTORY_POINTS);
|
||||
assert.equal(motion.fixes[0].at, 100_000);
|
||||
|
||||
const sparse = new LocalAdsbMotion();
|
||||
for (let minute = 0; minute < 15; minute += 1) {
|
||||
const at = minute * 60_000;
|
||||
sparse.observe(fix(at, minute * 60 * STEP), at);
|
||||
}
|
||||
assert.ok(
|
||||
sparse.fixes.at(-1).at - sparse.fixes[0].at <= LOCAL_ADSB_HISTORY_MS,
|
||||
);
|
||||
assert.equal(sparse.fixes.length, 11);
|
||||
});
|
||||
|
||||
test('repeated and out-of-order fixes do not move the anchor', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
assert.equal(motion.observe(fix(10_000, 0), 10_000), true);
|
||||
assert.equal(motion.observe(fix(10_000, 0), 10_500), false);
|
||||
assert.equal(motion.observe(fix(9_000, 0), 10_500), false);
|
||||
// A feed re-reading the same fix with a jittered, later time.
|
||||
assert.equal(motion.observe(fix(10_080, 0), 11_000), false);
|
||||
assert.equal(motion.fixes.length, 1);
|
||||
});
|
||||
|
||||
test('coasting stops 10 s after the last message', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
const coasted = motion.displayAt(30_000).lat;
|
||||
assert.ok(Math.abs(coasted - 10 * STEP) < 1e-5, `${coasted}`);
|
||||
});
|
||||
|
||||
test('three refused fixes in a row restart the track from the new stream', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
for (let index = 1; index <= 2; index += 1)
|
||||
assert.equal(
|
||||
motion.observe(fix(index * 1_000, 1 + index * STEP), index * 1_000),
|
||||
false,
|
||||
);
|
||||
assert.equal(motion.rejectedFixes, 2);
|
||||
assert.equal(motion.observe(fix(3_000, 1 + 3 * STEP), 3_000), true);
|
||||
assert.equal(motion.rejectedFixes, 3);
|
||||
assert.equal(
|
||||
motion.fixes.length,
|
||||
1,
|
||||
'the history restarts at the new stream',
|
||||
);
|
||||
assert.ok(Math.abs(motion.displayAt(3_000).lat - (1 + 3 * STEP)) < 1e-9);
|
||||
});
|
||||
|
||||
test('the last known speed bounds a fix whose record carries none', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
// 1.2 nm in 10 s (432 kt): under the 1,000 kt unknown-speed cap but far
|
||||
// beyond the 120 kt the aircraft last reported.
|
||||
assert.equal(
|
||||
motion.observe(fix(10_000, 1.2 / 60, { groundSpeedKt: null }), 10_000),
|
||||
false,
|
||||
);
|
||||
assert.equal(motion.rejectedFixes, 1);
|
||||
});
|
||||
|
||||
test('re-observing one refused fix never re-anchors on it', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
const outlier = fix(1_000, 1);
|
||||
// The layer re-syncs the same record several times a second.
|
||||
for (let sync = 0; sync < 6; sync += 1)
|
||||
assert.equal(motion.observe(outlier, 1_000 + sync * 250), false);
|
||||
assert.equal(motion.rejectedFixes, 1, 'one fix is refused once');
|
||||
assert.equal(motion.rejectStreak, 1);
|
||||
assert.ok(motion.displayAt(2_000).lat < 0.01, 'still on the old track');
|
||||
// A jittered re-read of the same refused position is the same fix too.
|
||||
assert.equal(motion.observe(fix(1_080, 1), 2_500), false);
|
||||
assert.equal(motion.rejectStreak, 1);
|
||||
});
|
||||
|
||||
test('an unchanged position still delivers newer altitude and velocity', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 120 }), 0);
|
||||
// A hovering helicopter: same coordinates, climbing, now stationary.
|
||||
motion.observe(
|
||||
fix(5_000, 0, {
|
||||
altitudeFt: 1_500,
|
||||
groundSpeedKt: 0,
|
||||
verticalRateFpm: 0,
|
||||
trackDeg: 90,
|
||||
}),
|
||||
5_000,
|
||||
);
|
||||
assert.equal(motion.fixes.length, 1, 'no duplicate trail point');
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
assert.equal(motion.anchor.trackDeg, 90);
|
||||
// Past the correction window the display settles on the new telemetry.
|
||||
const display = motion.displayAt(20_000);
|
||||
assert.ok(
|
||||
Math.abs(display.altitudeFt - 1_500) < 1e-6,
|
||||
`${display.altitudeFt}`,
|
||||
);
|
||||
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
|
||||
});
|
||||
|
||||
test('a delayed position never rolls back newer telemetry', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
const step100 = (100 * 1852) / 3600 / 111_195;
|
||||
assert.equal(
|
||||
motion.observe(
|
||||
fix(10_000, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }),
|
||||
10_000,
|
||||
),
|
||||
true,
|
||||
);
|
||||
// Velocity and altitude heard at 20 s, no new position.
|
||||
motion.observe(
|
||||
fix(10_000, 0, {
|
||||
altitudeFt: 1_500,
|
||||
groundSpeedKt: 0,
|
||||
lastMessageAt: 20_000,
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
assert.equal(motion.telemetryAt, 20_000);
|
||||
// Another receiver's record: a newer position (15 s) whose telemetry is
|
||||
// older than what the anchor already holds.
|
||||
const delayed = fix(15_000, 5 * step100, {
|
||||
altitudeFt: 1_000,
|
||||
groundSpeedKt: 100,
|
||||
});
|
||||
assert.equal(motion.observe(delayed, 21_000), true, 'the position is new');
|
||||
assert.equal(motion.fixes.length, 2);
|
||||
assert.equal(motion.anchor.lat, 5 * step100, 'the anchor moves');
|
||||
assert.equal(motion.anchor.speedMps, 0, 'telemetry stays 0 kt');
|
||||
assert.equal(motion.anchor.altitudeFt, 1_500, 'telemetry stays 1,500 ft');
|
||||
assert.equal(motion.telemetryAt, 20_000, 'telemetryAt never moves back');
|
||||
assert.equal(motion.fixes.at(-1).altitudeFt, 1_000, 'trail point as heard');
|
||||
const display = motion.displayAt(40_000);
|
||||
assert.ok(
|
||||
Math.abs(display.altitudeFt - 1_500) < 1e-6,
|
||||
`${display.altitudeFt}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(display.lat - 5 * step100) < 1e-9,
|
||||
'a stopped aircraft does not coast',
|
||||
);
|
||||
});
|
||||
|
||||
test('a velocity/altitude-only update with the same position time still refreshes telemetry', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), 0);
|
||||
// No new position message: only velocity and altitude are newer.
|
||||
const newer = fix(0, 0, {
|
||||
altitudeFt: 1_500,
|
||||
groundSpeedKt: 0,
|
||||
trackDeg: 90,
|
||||
lastMessageAt: 5_000,
|
||||
});
|
||||
assert.equal(motion.observe(newer, 5_000), false, 'no new trail point');
|
||||
assert.equal(motion.fixes.length, 1);
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
assert.equal(motion.anchor.altitudeFt, 1_500);
|
||||
assert.equal(motion.anchor.trackDeg, 90);
|
||||
// Re-reading that record is not newer telemetry, and an older message
|
||||
// never rolls it back.
|
||||
motion.observe(newer, 5_500);
|
||||
motion.observe(
|
||||
fix(0, 0, { altitudeFt: 900, groundSpeedKt: 300, lastMessageAt: 4_000 }),
|
||||
6_000,
|
||||
);
|
||||
assert.equal(motion.anchor.altitudeFt, 1_500);
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
const display = motion.displayAt(20_000);
|
||||
assert.ok(
|
||||
Math.abs(display.altitudeFt - 1_500) < 1e-6,
|
||||
`${display.altitudeFt}`,
|
||||
);
|
||||
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export const LAYER_ID = 'local-adsb';
|
||||
export const LAYER_NAME = 'Local ADS-B';
|
||||
export const LAYER_SOURCE = 'RTL-SDR · WebUSB';
|
||||
/** Distinct from the public Flights palette so both can be drawn together. */
|
||||
export const LOCAL_ADSB_COLOR = '#ff4fd8';
|
||||
/** Ring drawn around 978 MHz UAT aircraft: same magenta family, lighter. */
|
||||
export const LOCAL_ADSB_UAT_RING_COLOR = '#ffb3ef';
|
||||
export const ENTITY_PREFIX = `${LAYER_ID}:`;
|
||||
/** Re-evaluate marker freshness and the card's position age this often. */
|
||||
export const LOCAL_ADSB_TICK_MS = 1_000;
|
||||
/** Coalesce receiver bursts into at most one scene sync per interval. */
|
||||
export const LOCAL_ADSB_SYNC_MS = 200;
|
||||
export const HEARD_BY_RECEIVER = 'Heard by your receiver';
|
||||
export const BAND_LABELS = Object.freeze({
|
||||
1090: '1090 MHz',
|
||||
978: '978 MHz UAT',
|
||||
});
|
||||
export const SOURCE_LABELS = Object.freeze({
|
||||
webusb: 'browser SDR',
|
||||
feed: 'decoder feed',
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { localReceiverFeedName } from './feedNames.js';
|
||||
import { LAYER_SOURCE } from './policy.js';
|
||||
|
||||
/**
|
||||
* Layers-panel status for Local ADS-B, from its two inputs: the browser
|
||||
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
|
||||
*/
|
||||
|
||||
export const FEED_SOURCE = 'Decoder feeds';
|
||||
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
|
||||
|
||||
function plural(count, one, many) {
|
||||
return count === 1 ? one : many;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the feeds that are not live, grouped by status, e.g.
|
||||
* "feed 978 unreachable" or "feeds 1090, 978 stale".
|
||||
* @param {object[]} feeds Feed statuses from the route.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeFeedProblems(feeds) {
|
||||
const byStatus = new Map();
|
||||
for (const feed of feeds) {
|
||||
if (feed?.status === 'live') continue;
|
||||
const status = feed?.status || 'unreachable';
|
||||
if (!byStatus.has(status)) byStatus.set(status, new Set());
|
||||
byStatus.get(status).add(localReceiverFeedName(feed, feeds));
|
||||
}
|
||||
return [...byStatus]
|
||||
.map(([status, names]) => {
|
||||
const list = [...names];
|
||||
return `${plural(list.length, 'feed', 'feeds')} ${list.join(', ')} ${status}`;
|
||||
})
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/** Whether the server reported at least one configured feed. */
|
||||
export function feedsConfigured(feedState) {
|
||||
return Boolean(
|
||||
feedState?.configured === true &&
|
||||
Array.isArray(feedState.feeds) &&
|
||||
feedState.feeds.length,
|
||||
);
|
||||
}
|
||||
|
||||
function webUsbStatus(receiver, heard, feedState) {
|
||||
if (receiver.status === 'error')
|
||||
return { status: 'error', error: receiver.message };
|
||||
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||
return { loading: true, loadingLabel: 'opening receiver' };
|
||||
if (
|
||||
feedState?.polling &&
|
||||
feedState.configured === null &&
|
||||
!receiver.connected
|
||||
)
|
||||
return { loading: true, loadingLabel: 'checking decoder feeds' };
|
||||
if (!receiver.webUsbSupported)
|
||||
return {
|
||||
status: 'idle',
|
||||
statusMessage: 'WebUSB needs desktop Chrome or Edge',
|
||||
};
|
||||
if (!receiver.connected)
|
||||
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
|
||||
if (receiver.mode !== 'adsb')
|
||||
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
|
||||
const rate = Number.isFinite(receiver.messagesPerSecond)
|
||||
? `${receiver.messagesPerSecond} msg/s`
|
||||
: 'listening';
|
||||
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Status fields of `getStats()` for the Local ADS-B row.
|
||||
*
|
||||
* Without configured feeds this is exactly the WebUSB status. With feeds:
|
||||
* "2 feeds live · 14 heard" when every feed is live. While any input is
|
||||
* producing (the browser receiver streaming, or a feed live) the row stays
|
||||
* nominal and names the other feeds as a trailing note, e.g. "3 heard · USB
|
||||
* 5.8 msg/s · feed 1090 stale": a decoder stopped so the browser can take the
|
||||
* dongle is not a fault. With nothing producing, every readable feed stale is
|
||||
* STALE and anything else is an error.
|
||||
* @param {object} options
|
||||
* @param {object} options.receiver WebUSB receiver snapshot.
|
||||
* @param {object|null} options.feedState Feed poller snapshot.
|
||||
* @param {number} options.heard Merged aircraft heard in the last 60 s.
|
||||
* @returns {object} status / statusMessage / loading / loadingLabel / error /
|
||||
* stale / source.
|
||||
*/
|
||||
export function localAdsbStatus({ receiver, feedState, heard }) {
|
||||
if (!feedsConfigured(feedState))
|
||||
return {
|
||||
source: LAYER_SOURCE,
|
||||
...webUsbStatus(receiver, heard, feedState),
|
||||
};
|
||||
const usbActive = Boolean(receiver.connected && receiver.mode === 'adsb');
|
||||
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
|
||||
const live = feedState.feeds.filter((feed) => feed.status === 'live');
|
||||
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
|
||||
if (usbActive && receiver.status === 'error') problems.push('USB error');
|
||||
const rate =
|
||||
usbActive &&
|
||||
receiver.status !== 'error' &&
|
||||
Number.isFinite(receiver.messagesPerSecond)
|
||||
? ` · USB ${receiver.messagesPerSecond} msg/s`
|
||||
: '';
|
||||
const heardText = `${heard} heard`;
|
||||
if (!problems.length) {
|
||||
return {
|
||||
source,
|
||||
status: 'streaming',
|
||||
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
|
||||
};
|
||||
}
|
||||
const problemText = problems.join(' · ');
|
||||
const usbProducing = usbActive && receiver.status !== 'error';
|
||||
if (!live.length && !usbProducing) {
|
||||
// Decoders that stopped but still serve their last aircraft.json are
|
||||
// stale, not down: only an unreachable or unusable feed is an error.
|
||||
const readable = feedState.feeds.filter(
|
||||
(feed) => feed.status !== 'invalid',
|
||||
);
|
||||
if (readable.length && readable.every((feed) => feed.status === 'stale'))
|
||||
return {
|
||||
source,
|
||||
status: 'stale',
|
||||
stale: true,
|
||||
statusMessage: `${problemText} · ${heardText}`,
|
||||
};
|
||||
return {
|
||||
source,
|
||||
status: 'error',
|
||||
error: heard > 0 ? `${problemText} · ${heardText}` : problemText,
|
||||
};
|
||||
}
|
||||
return {
|
||||
source,
|
||||
status: 'streaming',
|
||||
loadingLabel: `${heardText}${rate} · ${problemText}`,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
trailAnchorForModel,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
|
||||
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
|
||||
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
@@ -151,7 +153,7 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
|
||||
if (isCockpitContact && !isCockpitNear) {
|
||||
const freshnessAlpha = bb.color?.alpha ?? 1;
|
||||
const freshnessAlpha = cyberSonarBaseAlpha(bb);
|
||||
bb.image = cockpitContactDotImage();
|
||||
bb.width = COCKPIT_CONTACT_SIZE_PX;
|
||||
bb.height = COCKPIT_CONTACT_SIZE_PX;
|
||||
@@ -171,7 +173,7 @@ export function createRendering({
|
||||
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
|
||||
bb.scale = _militaryBillboardScale(icao24) * limbScale;
|
||||
bb.scaleByDistance = _normalBillboardScaleByDistance();
|
||||
bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1);
|
||||
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
|
||||
}
|
||||
|
||||
/** Sprite kind for one contact's billboard. Identity for every aircraft except
|
||||
@@ -892,25 +894,11 @@ export function createRendering({
|
||||
);
|
||||
flightState._lastModelCapWarnMs = nowMs;
|
||||
}
|
||||
modelEligible = new Set();
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
} // 1. KEEP on-screen
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
} // 2. ADD on-screen
|
||||
for (const [icao, , inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
|
||||
} // 3. KEEP off-screen (can't starve visible)
|
||||
for (const [icao, d2, inF] of cand) {
|
||||
if (modelEligible.size >= cap) break;
|
||||
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
|
||||
modelEligible.add(icao);
|
||||
} // 4. ADD off-screen leftover
|
||||
modelEligible = selectModelEligible(cand, {
|
||||
cap,
|
||||
addDistSq,
|
||||
isModeled: (icao) => flightState._models.has(icao),
|
||||
});
|
||||
const toRelease = [];
|
||||
for (const icao of flightState._models.keys()) {
|
||||
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
|
||||
@@ -977,6 +965,7 @@ export function createRendering({
|
||||
const isCockpitNear =
|
||||
flightState._cockpitContactMode &&
|
||||
flightState._cockpitNearContacts.has(icao24);
|
||||
const baseColor = MIL_ICON_COLOR;
|
||||
const treatment = applyAircraftBillboardTreatment({
|
||||
billboard: bb,
|
||||
baseScale:
|
||||
@@ -984,7 +973,7 @@ export function createRendering({
|
||||
? 1
|
||||
: _militaryBillboardScale(icao24),
|
||||
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
|
||||
baseColor: MIL_ICON_COLOR,
|
||||
baseColor,
|
||||
focusFactor: focus.factor,
|
||||
cameraDistanceM,
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
|
||||
@@ -227,6 +227,8 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._scratchFleetPos = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchSonarScreen = new Cesium.Cartesian2();
|
||||
|
||||
flightState._scratchDrRaw = new Cesium.Cartesian3();
|
||||
|
||||
flightState._scratchWarmupTime = new Cesium.JulianDate();
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
|
||||
|
||||
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
|
||||
|
||||
/** CSS accent for a perimeter card by containment progress (mirrors the fill). */
|
||||
export function containmentAccent(containedPct) {
|
||||
if (!Number.isFinite(containedPct) || containedPct <= 0) return '#ff3b30';
|
||||
if (containedPct < 50) return '#ff7a00';
|
||||
if (containedPct < 100) return '#ffb300';
|
||||
return '#8bc34a';
|
||||
}
|
||||
|
||||
/** Shoelace area (degree², sign dropped) — relative sizes only. */
|
||||
function ringArea(ring) {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < ring.length - 1; i++) {
|
||||
const [x1, y1] = ring[i];
|
||||
const [x2, y2] = ring[i + 1];
|
||||
sum += x1 * y2 - x2 * y1;
|
||||
}
|
||||
return Math.abs(sum / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anchor point for the incident card: the centroid of the largest polygon's
|
||||
* outer ring. Degrees, so callers own the Cartesian conversion.
|
||||
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
|
||||
* @returns {{lon: number, lat: number}}
|
||||
*/
|
||||
export function perimeterAnchorDegrees(polygons) {
|
||||
let best = null;
|
||||
let bestArea = -1;
|
||||
for (const rings of polygons) {
|
||||
const outer = rings[0];
|
||||
const area = ringArea(outer);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = outer;
|
||||
}
|
||||
}
|
||||
// Vertex mean over the closed ring (first == last point dropped): perimeters
|
||||
// are compact enough that this stays inside or near the polygon.
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
const count = best.length - 1;
|
||||
for (let i = 0; i < count; i++) {
|
||||
lon += best[i][0];
|
||||
lat += best[i][1];
|
||||
}
|
||||
return { lon: lon / count, lat: lat / count };
|
||||
}
|
||||
|
||||
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
|
||||
function formatCost(dollars) {
|
||||
if (!Number.isFinite(dollars) || dollars < 1000) return null;
|
||||
const units = [
|
||||
[1e3, 'K'],
|
||||
[1e6, 'M'],
|
||||
[1e9, 'B'],
|
||||
];
|
||||
let index = 0;
|
||||
for (let i = units.length - 1; i >= 0; i--) {
|
||||
if (dollars >= units[i][0]) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 999,500 must promote to $1M, never render as $1000K.
|
||||
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
|
||||
const value = dollars / units[index][0];
|
||||
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
|
||||
}
|
||||
|
||||
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
|
||||
function titleCase(name) {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function formatAge(deltaMs) {
|
||||
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
|
||||
const hours = Math.floor(deltaMs / 3600000);
|
||||
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the overlay-host entry for one selected incident. The caller supplies
|
||||
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
|
||||
* @param {Object} row - Normalized perimeter row (see records.js).
|
||||
* @param {number} nowMs - Current epoch milliseconds.
|
||||
* @param {{link: ?string}} [extras] - Optional InciWeb page for this incident.
|
||||
* @returns {Object} World-overlay entry without `position`.
|
||||
*/
|
||||
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
|
||||
const facts = [];
|
||||
if (Number.isFinite(row.acres))
|
||||
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
|
||||
facts.push(
|
||||
Number.isFinite(row.containedPct)
|
||||
? `${Math.round(row.containedPct)}% contained`
|
||||
: 'containment unknown',
|
||||
);
|
||||
if (row.state) facts.push(row.state);
|
||||
else if (row.category) facts.push(row.category);
|
||||
|
||||
const situation = [];
|
||||
if (row.cause) situation.push(`${row.cause} cause`);
|
||||
if (row.behavior) situation.push(row.behavior);
|
||||
if (row.complexity) situation.push(row.complexity);
|
||||
|
||||
const response = [];
|
||||
if (Number.isFinite(row.personnel))
|
||||
response.push(
|
||||
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
|
||||
);
|
||||
if (row.county) response.push(`${row.county} County`);
|
||||
if (Number.isFinite(row.costToDate)) {
|
||||
const cost = formatCost(row.costToDate);
|
||||
if (cost) response.push(`${cost} to date`);
|
||||
}
|
||||
|
||||
const ages = [];
|
||||
const discovered = formatAge(nowMs - row.discoveredTime);
|
||||
if (row.discoveredTime != null && discovered)
|
||||
ages.push(`discovered ${discovered} ago`);
|
||||
const updated = formatAge(nowMs - row.updatedTime);
|
||||
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
|
||||
|
||||
const details = [facts.join(' · ')];
|
||||
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
|
||||
if (situation.length) details.push(situation.join(' · '));
|
||||
if (response.length) details.push(response.join(' · '));
|
||||
if (ages.length) details.push(ages.join(' · '));
|
||||
if (link) details.push('InciWeb ↗ · click card to open');
|
||||
|
||||
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
|
||||
return {
|
||||
id: `fire-perimeter-card:${row.stableId}`,
|
||||
// `selected` picks the paint lane and collision protection; an explicit
|
||||
// paintLane would override it in the host's lane resolver.
|
||||
selected: true,
|
||||
// Only a linked card is clickable — the overlay host registers hit
|
||||
// rectangles solely for interactive entries.
|
||||
interactive: Boolean(link),
|
||||
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
|
||||
title,
|
||||
details,
|
||||
accent: containmentAccent(row.containedPct),
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
gapPx: 15,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { perimeterAnchorDegrees, buildIncidentCard } from './cards.js';
|
||||
|
||||
const square = [
|
||||
[-108.2, 35.0],
|
||||
[-108.0, 35.0],
|
||||
[-108.0, 35.2],
|
||||
[-108.2, 35.2],
|
||||
[-108.2, 35.0],
|
||||
];
|
||||
const tiny = [
|
||||
[-107.01, 34.0],
|
||||
[-107.0, 34.0],
|
||||
[-107.0, 34.01],
|
||||
[-107.01, 34.0],
|
||||
];
|
||||
|
||||
test('the card anchors at the centroid of the largest polygon', () => {
|
||||
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
|
||||
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
|
||||
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
|
||||
});
|
||||
|
||||
test('a full incident row renders name, size, containment, and ages', () => {
|
||||
const now = 1758000000000;
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: '2026-NMGNF-000123',
|
||||
name: 'Frijoles',
|
||||
acres: 15956.4,
|
||||
containedPct: 74,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: now - 36 * 3600000,
|
||||
updatedTime: now - 2 * 3600000,
|
||||
cause: 'Natural',
|
||||
behavior: 'Active',
|
||||
personnel: 380,
|
||||
county: 'Sandoval',
|
||||
costToDate: 4200000,
|
||||
complexity: 'Type 3 Incident',
|
||||
},
|
||||
now,
|
||||
);
|
||||
assert.equal(card.id, 'fire-perimeter-card:2026-NMGNF-000123');
|
||||
assert.equal(card.title, 'FIRE · Frijoles');
|
||||
assert.deepEqual(card.details, [
|
||||
'15,956 ac · 74% contained · US-NM',
|
||||
'Natural cause · Active · Type 3 Incident',
|
||||
'380 personnel · Sandoval County · $4.2M to date',
|
||||
'discovered 1d ago · updated 2h ago',
|
||||
]);
|
||||
assert.equal(card.selected, true);
|
||||
assert.equal(typeof card.accent, 'string');
|
||||
});
|
||||
|
||||
test('missing attributes degrade to available facts instead of placeholders', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: null,
|
||||
acres: null,
|
||||
containedPct: null,
|
||||
state: null,
|
||||
category: 'RX',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.equal(card.title, 'FIRE · Unnamed incident');
|
||||
assert.deepEqual(card.details, ['containment unknown · RX']);
|
||||
});
|
||||
|
||||
test('a known InciWeb page adds a link line to the card', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Timber',
|
||||
acres: 25426,
|
||||
containedPct: 44,
|
||||
state: 'US-CA',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
{
|
||||
link: 'https://inciweb.wildfire.gov/incident-information/calpf-timber-fire',
|
||||
},
|
||||
);
|
||||
assert.equal(card.details.at(-1), 'InciWeb ↗ · click card to open');
|
||||
assert.equal(card.interactive, true);
|
||||
assert.match(card.accessibilityLabel, /InciWeb/);
|
||||
});
|
||||
|
||||
test('the selected card claims the selected paint lane, not the ambient lane', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Any',
|
||||
acres: 1,
|
||||
containedPct: 1,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
complexName: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.equal(card.selected, true);
|
||||
// An explicit paintLane would override `selected` in the overlay host's
|
||||
// lane resolver and push the card under other selected cards.
|
||||
assert.equal('paintLane' in card, false);
|
||||
assert.equal('collisionGroup' in card, false);
|
||||
});
|
||||
|
||||
test('costs near a unit boundary promote to the larger unit', () => {
|
||||
const base = {
|
||||
stableId: 'x',
|
||||
name: 'Any',
|
||||
acres: null,
|
||||
containedPct: null,
|
||||
state: null,
|
||||
category: null,
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
complexity: null,
|
||||
complexName: null,
|
||||
};
|
||||
const costLine = (costToDate) =>
|
||||
buildIncidentCard({ ...base, costToDate }, 0).details.find((line) =>
|
||||
line.includes('to date'),
|
||||
);
|
||||
assert.equal(costLine(999500), '$1M to date');
|
||||
assert.equal(costLine(999499), '$999K to date');
|
||||
assert.equal(costLine(999600000), '$1B to date');
|
||||
assert.equal(costLine(4200000), '$4.2M to date');
|
||||
});
|
||||
|
||||
test('a complex member names its complex on the card', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Crosswhite',
|
||||
acres: 342923,
|
||||
containedPct: 99,
|
||||
state: 'US-OR',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.ok(card.details.includes('part of Rowe Creek Complex'));
|
||||
});
|
||||
|
||||
test('a card without a link is not interactive', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Crosswhite',
|
||||
acres: 1,
|
||||
containedPct: 1,
|
||||
state: 'US-OR',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.equal(card.interactive, false);
|
||||
});
|
||||
|
||||
test('partial incident details render only the facts that exist', () => {
|
||||
const card = buildIncidentCard(
|
||||
{
|
||||
stableId: 'x',
|
||||
name: 'Whiskey',
|
||||
acres: 542,
|
||||
containedPct: 48,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: 'Human',
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: 'Catron',
|
||||
costToDate: 85000,
|
||||
complexity: null,
|
||||
},
|
||||
1758000000000,
|
||||
);
|
||||
assert.deepEqual(card.details, [
|
||||
'542 ac · 48% contained · US-NM',
|
||||
'Human cause',
|
||||
'Catron County · $85K to date',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/** InciWeb incident catalog: full-index fetch and WFIGS-name matching. */
|
||||
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
/**
|
||||
* Normalize an incident title for matching: lowercase, collapsed whitespace,
|
||||
* a leading year dropped ('2026 Coleman Creek'), a trailing 'Fire' dropped
|
||||
* ('Coyote Fire') — InciWeb titles carry both decorations inconsistently and
|
||||
* WFIGS names carry neither.
|
||||
*/
|
||||
function normalizeName(name) {
|
||||
return String(name)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^(19|20)\d{2} /, '')
|
||||
.replace(/ fire$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one WFIGS incident against the publication catalog.
|
||||
* Only rows whose normalized title equals the incident's normalized name
|
||||
* count. Ambiguity resolves by the tau dispatch unit's state prefix, then
|
||||
* by newest incident id (same-state duplicates are usually reburns of the
|
||||
* same name). The `/node/{id}` link 301s to the canonical page, so no slug
|
||||
* construction is needed.
|
||||
* @param {?Array} publications - Publication catalog rows.
|
||||
* @param {{name: ?string, state: ?string}} incident - WFIGS row facts.
|
||||
* @returns {?string} InciWeb node URL.
|
||||
*/
|
||||
export function resolveInciwebNodeLink(publications, { name, state }) {
|
||||
if (!Array.isArray(publications) || !name) return null;
|
||||
const wanted = normalizeName(name);
|
||||
let candidates = publications.filter(
|
||||
(row) =>
|
||||
typeof row?.incident_title === 'string' &&
|
||||
/^\d+$/.test(String(row?.incident_id ?? '')) &&
|
||||
normalizeName(row.incident_title) === wanted,
|
||||
);
|
||||
// The catalog is all-time and nationwide, so the dispatch unit's state
|
||||
// must corroborate every match — a globally unique title is NOT evidence
|
||||
// it's the same fire (live data shows same-name incidents across states).
|
||||
const statePrefix =
|
||||
typeof state === 'string' && /^US-[A-Za-z]{2}$/.test(state)
|
||||
? state.slice(3).toLowerCase()
|
||||
: null;
|
||||
if (statePrefix) {
|
||||
candidates = candidates.filter(
|
||||
(row) =>
|
||||
typeof row.tau === 'string' &&
|
||||
row.tau.slice(0, 2).toLowerCase() === statePrefix,
|
||||
);
|
||||
} else if (candidates.length > 1) {
|
||||
return null;
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
// Same-state duplicates are usually reburns of the same name — newest
|
||||
// wins here, and the publication-currency check guards the final link.
|
||||
candidates = [
|
||||
candidates.reduce((a, b) =>
|
||||
Number(a.incident_id) >= Number(b.incident_id) ? a : b,
|
||||
),
|
||||
];
|
||||
}
|
||||
return candidates.length === 1
|
||||
? `https://inciweb.wildfire.gov/node/${candidates[0].incident_id}`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The publication id inside a node link produced above, or null. */
|
||||
export function inciwebNodeId(link) {
|
||||
const match = /^https:\/\/inciweb\.wildfire\.gov\/node\/(\d+)$/.exec(
|
||||
String(link ?? ''),
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
// A fire's InciWeb page is created around the incident's start; a match
|
||||
// whose page predates discovery by more than this is an archived
|
||||
// same-name incident from a earlier season.
|
||||
const CREATED_BEFORE_DISCOVERY_MS = 60 * 24 * 3600000;
|
||||
// Without a discovery date, require recent editorial activity on the page.
|
||||
const CHANGED_WITHIN_MS = 180 * 24 * 3600000;
|
||||
|
||||
/**
|
||||
* Whether a publication plausibly describes the given current incident.
|
||||
* Fails closed on unusable timestamps.
|
||||
* @param {{createdMs: ?number, changedMs: ?number}} publication
|
||||
* @param {{discoveredTime: ?number, nowMs?: number}} incident
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCurrentPublication(
|
||||
{ createdMs, changedMs },
|
||||
{ discoveredTime, nowMs = Date.now() },
|
||||
) {
|
||||
if (Number.isFinite(discoveredTime) && Number.isFinite(createdMs)) {
|
||||
return createdMs >= discoveredTime - CREATED_BEFORE_DISCOVERY_MS;
|
||||
}
|
||||
if (Number.isFinite(changedMs)) {
|
||||
return nowMs - changedMs <= CHANGED_WITHIN_MS;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Fetch one publication's created/changed timestamps for currency checks. */
|
||||
export function createInciwebPublicationSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getPublication(id, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/fire-perimeters/inciweb/publication/${encodeURIComponent(id)}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
512 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
createdMs: Number.isFinite(payload?.createdMs)
|
||||
? payload.createdMs
|
||||
: null,
|
||||
changedMs: Number.isFinite(payload?.changedMs)
|
||||
? payload.changedMs
|
||||
: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one WFIGS incident to its InciWeb page, or null.
|
||||
* The incident's own name is tried first; a member of a complex whose own
|
||||
* name has no page falls back to the complex's page (InciWeb tracks the
|
||||
* managing complex, not each member fire). Anything ambiguous yields null
|
||||
* rather than a wrong page.
|
||||
* @param {?Array} publications - Publication catalog rows.
|
||||
* @param {{name: ?string, state: ?string, complexName: ?string}} incident
|
||||
* - WFIGS row facts.
|
||||
* @returns {?string} InciWeb URL.
|
||||
*/
|
||||
export function findInciwebLink(publications, { name, state, complexName }) {
|
||||
return (
|
||||
resolveInciwebNodeLink(publications, { name, state }) ??
|
||||
resolveInciwebNodeLink(publications, { name: complexName, state })
|
||||
);
|
||||
}
|
||||
|
||||
/** Fetch the full InciWeb incident catalog. */
|
||||
export function createInciwebIndexSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getIndex({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters/inciweb/index', {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
|
||||
const rows = await readResponseJsonCapped(
|
||||
response,
|
||||
4 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
findInciwebLink,
|
||||
resolveInciwebNodeLink,
|
||||
createInciwebIndexSource,
|
||||
createInciwebPublicationSource,
|
||||
isCurrentPublication,
|
||||
inciwebNodeId,
|
||||
} from './inciweb.js';
|
||||
|
||||
const catalog = [
|
||||
{
|
||||
incident_id: '322812',
|
||||
tau: 'NMSNF Gobernador Pile Burn Coyote Ranger District',
|
||||
incident_title: 'Gobernador Pile Burn - Coyote Ranger District',
|
||||
},
|
||||
{
|
||||
incident_id: '327504',
|
||||
tau: 'SDBKF Coyote Flats Fire',
|
||||
incident_title: 'Coyote Flats Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '328922',
|
||||
tau: 'OR95S Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '329195',
|
||||
tau: 'ORBUD Second Flat',
|
||||
incident_title: 'Second Flat',
|
||||
},
|
||||
{
|
||||
incident_id: '329273',
|
||||
tau: 'ORBUD 2026 Coleman Creek',
|
||||
incident_title: '2026 Coleman Creek',
|
||||
},
|
||||
{
|
||||
incident_id: '328923',
|
||||
tau: 'ORPRD Rowe Creek Complex',
|
||||
incident_title: 'Rowe Creek Complex',
|
||||
},
|
||||
{
|
||||
incident_id: '291765',
|
||||
tau: 'CALPF Timber Fire',
|
||||
incident_title: 'Timber Fire',
|
||||
},
|
||||
];
|
||||
|
||||
test('incidents resolve by exact normalized title to a node link', () => {
|
||||
// Title formats vary: with/without a 'Fire' suffix, with/without a year
|
||||
// prefix ('2026 Coleman Creek'). All normalize to the WFIGS name.
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Second Flat', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/329195',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Coleman Creek', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/329273',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Timber', state: 'US-CA' }),
|
||||
'https://inciweb.wildfire.gov/node/291765',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Coyote', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/328922',
|
||||
);
|
||||
// Substring-style near-misses never match.
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Gobernador', state: 'US-NM' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(catalog, { name: 'Nope', state: null }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(null, { name: 'Coyote', state: 'US-OR' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a unique candidate in the wrong state is rejected, not linked', () => {
|
||||
// The catalog is all-time and nationwide: a current Arizona "Willow"
|
||||
// must not link to the only "Willow Fire" on file when it's a Montana
|
||||
// incident.
|
||||
const willow = [
|
||||
{
|
||||
incident_id: '300001',
|
||||
tau: 'MTFNF Willow Fire',
|
||||
incident_title: 'Willow Fire',
|
||||
},
|
||||
];
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-AZ' }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-MT' }),
|
||||
'https://inciweb.wildfire.gov/node/300001',
|
||||
);
|
||||
});
|
||||
|
||||
test('ambiguous titles disambiguate by state, then newest id', () => {
|
||||
const twoCoyotes = [
|
||||
{
|
||||
incident_id: '100',
|
||||
tau: 'AZASF Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '328922',
|
||||
tau: 'OR95S Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
];
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: 'US-AZ' }),
|
||||
'https://inciweb.wildfire.gov/node/100',
|
||||
);
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: null }),
|
||||
null,
|
||||
);
|
||||
const twoSameState = [
|
||||
{
|
||||
incident_id: '100',
|
||||
tau: 'ORXXX Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
{
|
||||
incident_id: '328922',
|
||||
tau: 'OR95S Coyote Fire',
|
||||
incident_title: 'Coyote Fire',
|
||||
},
|
||||
];
|
||||
// Same-state duplicates are usually reburns of the same name — newest wins.
|
||||
assert.equal(
|
||||
resolveInciwebNodeLink(twoSameState, { name: 'Coyote', state: 'US-OR' }),
|
||||
'https://inciweb.wildfire.gov/node/328922',
|
||||
);
|
||||
});
|
||||
|
||||
test('a complex member falls back to its complex page', () => {
|
||||
assert.equal(
|
||||
findInciwebLink(catalog, {
|
||||
name: 'Crosswhite',
|
||||
state: 'US-OR',
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
}),
|
||||
'https://inciweb.wildfire.gov/node/328923',
|
||||
);
|
||||
// A fire with its own page keeps it even when it belongs to a complex.
|
||||
assert.equal(
|
||||
findInciwebLink(catalog, {
|
||||
name: 'Timber',
|
||||
state: 'US-CA',
|
||||
complexName: 'SOME COMPLEX',
|
||||
}),
|
||||
'https://inciweb.wildfire.gov/node/291765',
|
||||
);
|
||||
assert.equal(
|
||||
findInciwebLink(catalog, {
|
||||
name: 'Nope',
|
||||
state: 'US-NM',
|
||||
complexName: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a node link exposes its publication id', () => {
|
||||
assert.equal(
|
||||
inciwebNodeId('https://inciweb.wildfire.gov/node/329195'),
|
||||
'329195',
|
||||
);
|
||||
assert.equal(inciwebNodeId('https://example.com/other'), null);
|
||||
assert.equal(inciwebNodeId(null), null);
|
||||
});
|
||||
|
||||
test('publication currency accepts pages created around or after discovery', () => {
|
||||
const day = 24 * 3600000;
|
||||
const discovery = 1785000000000;
|
||||
// Created shortly before discovery (page opened as the fire started).
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: discovery - 10 * day, changedMs: discovery + 30 * day },
|
||||
{ discoveredTime: discovery },
|
||||
),
|
||||
true,
|
||||
);
|
||||
// Created years before discovery: an archived same-name incident.
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: discovery - 900 * day, changedMs: discovery - 800 * day },
|
||||
{ discoveredTime: discovery },
|
||||
),
|
||||
false,
|
||||
);
|
||||
// No discovery date: fall back to recent activity on the page.
|
||||
const now = 1787600000000;
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: now - 900 * day, changedMs: now - 20 * day },
|
||||
{ discoveredTime: null, nowMs: now },
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: now - 900 * day, changedMs: now - 400 * day },
|
||||
{ discoveredTime: null, nowMs: now },
|
||||
),
|
||||
false,
|
||||
);
|
||||
// Unusable timestamps fail closed.
|
||||
assert.equal(
|
||||
isCurrentPublication(
|
||||
{ createdMs: null, changedMs: null },
|
||||
{ discoveredTime: discovery },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('the publication source fetches timestamps and honors cancellation', async () => {
|
||||
let requested;
|
||||
const source = createInciwebPublicationSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
createdMs: 1785025540000,
|
||||
changedMs: 1787589503000,
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
const publication = await source.getPublication('329195');
|
||||
assert.equal(requested, '/api/fire-perimeters/inciweb/publication/329195');
|
||||
assert.deepEqual(publication, {
|
||||
createdMs: 1785025540000,
|
||||
changedMs: 1787589503000,
|
||||
});
|
||||
|
||||
const malformed = createInciwebPublicationSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => JSON.stringify({ nope: 1 }),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await malformed.getPublication('1'), {
|
||||
createdMs: null,
|
||||
changedMs: null,
|
||||
});
|
||||
|
||||
const failing = createInciwebPublicationSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(failing.getPublication('1'), /InciWeb HTTP 503/);
|
||||
|
||||
const abort = new AbortController();
|
||||
const cancelled = createInciwebPublicationSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => {
|
||||
abort.abort();
|
||||
return JSON.stringify({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
cancelled.getPublication('1', { signal: abort.signal }),
|
||||
{
|
||||
name: 'AbortError',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the index source requests the same-origin full catalog', async () => {
|
||||
let request;
|
||||
const source = createInciwebIndexSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
request = { url: String(url), options };
|
||||
return {
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => JSON.stringify(catalog),
|
||||
};
|
||||
},
|
||||
});
|
||||
const rows = await source.getIndex();
|
||||
assert.equal(rows.length, catalog.length);
|
||||
assert.equal(request.url, '/api/fire-perimeters/inciweb/index');
|
||||
assert.equal(request.options.method, undefined);
|
||||
assert.equal(request.options.body, undefined);
|
||||
});
|
||||
|
||||
test('the index source rejects failures and honors cancellation', async () => {
|
||||
const failing = createInciwebIndexSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(failing.getIndex(), /InciWeb HTTP 503/);
|
||||
|
||||
const malformed = createInciwebIndexSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => JSON.stringify({ nope: 1 }),
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(await malformed.getIndex(), []);
|
||||
|
||||
const abort = new AbortController();
|
||||
const cancelled = createInciwebIndexSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => {
|
||||
abort.abort();
|
||||
return JSON.stringify(catalog);
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(cancelled.getIndex({ signal: abort.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
PERIMETER_OVERLAY_SOURCE_ID,
|
||||
perimeterAnchorDegrees,
|
||||
buildIncidentCard,
|
||||
containmentAccent,
|
||||
} from './cards.js';
|
||||
import {
|
||||
findInciwebLink,
|
||||
inciwebNodeId,
|
||||
isCurrentPublication,
|
||||
} from './inciweb.js';
|
||||
export { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
export { createWfigsPerimeterSource } from './source.js';
|
||||
export * from './cards.js';
|
||||
export * from './inciweb.js';
|
||||
|
||||
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
|
||||
export function containmentColor(containedPct) {
|
||||
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
|
||||
}
|
||||
|
||||
const ringPositions = (ring) =>
|
||||
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||
|
||||
const PICK_PREFIX = 'fire-perimeter:';
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
cohortLimit: 1,
|
||||
collisionCapacity: 1,
|
||||
moving: false,
|
||||
});
|
||||
// The InciWeb catalog changes on the order of days; refreshing it on the
|
||||
// 5-minute perimeter cadence would hammer a public .gov endpoint for ~200 KB
|
||||
// of near-identical bytes.
|
||||
const INCIWEB_INDEX_TTL_MS = 6 * 3600000;
|
||||
|
||||
/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */
|
||||
export function createFirePerimetersLayer({
|
||||
source,
|
||||
overlayHost = null,
|
||||
screenSpaceEventHandlerFactory = null,
|
||||
picking = null,
|
||||
pointer = null,
|
||||
inciwebSource = null,
|
||||
inciwebPublications = null,
|
||||
openExternal = null,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('Fire perimeters require a snapshot source');
|
||||
let _viewer = null;
|
||||
let _request = null;
|
||||
let _linkRequest = null;
|
||||
let _snapshotSignature = null;
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
let _selectedId = null;
|
||||
let _selectedLink = null;
|
||||
let _selectedCardId = null;
|
||||
let _inciwebIndex = [];
|
||||
let _indexFetchedAt = 0;
|
||||
let _indexFetchInFlight = false;
|
||||
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
|
||||
const _rowById = new Map();
|
||||
// Link → currency verdict from the publication check; unknown links are
|
||||
// absent. Failed checks are not cached so a transient outage retries.
|
||||
const _linkVerdicts = new Map();
|
||||
function abortLinkVerification() {
|
||||
_linkRequest?.controller.abort();
|
||||
_linkRequest = null;
|
||||
}
|
||||
|
||||
const canSelect = () =>
|
||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
||||
|
||||
/**
|
||||
* Refresh the InciWeb catalog off the render path: a hanging or failed
|
||||
* fetch only costs the link line, never the perimeter refresh — the
|
||||
* catalog is consumed solely by the selected card.
|
||||
*/
|
||||
function refreshInciwebIndex(signal) {
|
||||
if (!inciwebSource || _indexFetchInFlight) return;
|
||||
if (Date.now() - _indexFetchedAt < INCIWEB_INDEX_TTL_MS) return;
|
||||
_indexFetchInFlight = true;
|
||||
inciwebSource
|
||||
.getIndex({ signal })
|
||||
.then((rows) => {
|
||||
if (signal.aborted || !_enabled || !Array.isArray(rows)) return;
|
||||
_inciwebIndex = rows;
|
||||
_indexFetchedAt = Date.now();
|
||||
if (_selectedId) publishSelectedCard();
|
||||
})
|
||||
.catch(() => {
|
||||
// Catalog unavailable — cards stay linkless until the next attempt.
|
||||
})
|
||||
.finally(() => {
|
||||
_indexFetchInFlight = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a matched publication describes the current incident, not
|
||||
* an archived same-name fire — the catalog is all-time. Async: the card
|
||||
* republishes with the link once (and only if) the check passes.
|
||||
*/
|
||||
function verifyLink(link, row) {
|
||||
const id = inciwebNodeId(link);
|
||||
if (!id || !inciwebPublications || _linkRequest?.link === link) return;
|
||||
abortLinkVerification();
|
||||
const request = { link, controller: new AbortController() };
|
||||
_linkRequest = request;
|
||||
const { signal } = request.controller;
|
||||
inciwebPublications
|
||||
.getPublication(id, { signal })
|
||||
.then((publication) => {
|
||||
if (signal.aborted || _linkRequest !== request || !_enabled) return;
|
||||
_linkVerdicts.set(
|
||||
link,
|
||||
isCurrentPublication(publication, {
|
||||
discoveredTime: row.discoveredTime,
|
||||
}),
|
||||
);
|
||||
if (_selectedId === row.stableId) publishSelectedCard();
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail closed without caching: no link now, retry on reselection.
|
||||
})
|
||||
.finally(() => {
|
||||
if (_linkRequest === request) _linkRequest = null;
|
||||
});
|
||||
}
|
||||
|
||||
function publishSelectedCard() {
|
||||
if (!canSelect()) return;
|
||||
const row = _selectedId ? _rowById.get(_selectedId) : null;
|
||||
if (!row) {
|
||||
_selectedId = null;
|
||||
_selectedLink = null;
|
||||
_selectedCardId = null;
|
||||
overlayHost.setEntries(
|
||||
PERIMETER_OVERLAY_SOURCE_ID,
|
||||
[],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const candidate = findInciwebLink(_inciwebIndex, row);
|
||||
if (candidate && !_linkVerdicts.has(candidate)) verifyLink(candidate, row);
|
||||
_selectedLink =
|
||||
candidate && _linkVerdicts.get(candidate) === true ? candidate : null;
|
||||
const card = {
|
||||
...buildIncidentCard(row, Date.now(), { link: _selectedLink }),
|
||||
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
|
||||
};
|
||||
if (!openExternal) card.interactive = false;
|
||||
if (_selectedLink && openExternal) {
|
||||
const link = _selectedLink;
|
||||
// Keyboard/assistive activation mirrors the pointer click-through.
|
||||
card.activate = () => {
|
||||
openExternal(link);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
_selectedCardId = card.id;
|
||||
overlayHost.setEntries(
|
||||
PERIMETER_OVERLAY_SOURCE_ID,
|
||||
[card],
|
||||
CARD_HOST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a scene pick to one of this layer's incident ids, or null. */
|
||||
function pickedIncidentId(picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0];
|
||||
return _rowById.has(incidentId) ? incidentId : null;
|
||||
}
|
||||
|
||||
function installClickHandler() {
|
||||
if (!canSelect() || _clickHandler || !_viewer) return;
|
||||
// Deliberately NOT registered in the pick-ownership registry: sibling
|
||||
// layers yield to owned picks before their own overlay-card hit-tests,
|
||||
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||
// cards inert anywhere over a perimeter. Unowned, our picks read as
|
||||
// "empty space" to siblings — the pre-existing behavior for the globe.
|
||||
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
|
||||
_clickHandler.setInputAction((click) => {
|
||||
if (pointer && !pointer.isPointerFree()) return;
|
||||
// A click on the incident card itself opens its InciWeb page (when the
|
||||
// incident has one) and never disturbs the selection.
|
||||
const cardHit = overlayHost.hitTest?.(
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{ sourceId: PERIMETER_OVERLAY_SOURCE_ID },
|
||||
);
|
||||
if (cardHit && cardHit.entryId === _selectedCardId) {
|
||||
if (_selectedLink && openExternal) openExternal(_selectedLink);
|
||||
return;
|
||||
}
|
||||
const picked = _viewer.scene.pick(click.position);
|
||||
const incidentId = picked ? pickedIncidentId(picked) : null;
|
||||
if (incidentId) {
|
||||
abortLinkVerification();
|
||||
_selectedId = incidentId;
|
||||
publishSelectedCard();
|
||||
return;
|
||||
}
|
||||
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
|
||||
// "empty space" — leave the selection alone and let that layer handle it.
|
||||
if (picked) {
|
||||
const pickId = picking.resolvePickId(picked);
|
||||
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
|
||||
}
|
||||
if (_selectedId) {
|
||||
abortLinkVerification();
|
||||
_selectedId = null;
|
||||
publishSelectedCard();
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function removeClickHandler() {
|
||||
if (_clickHandler) {
|
||||
_clickHandler.destroy();
|
||||
_clickHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
abortLinkVerification();
|
||||
_selectedId = null;
|
||||
_selectedLink = null;
|
||||
_selectedCardId = null;
|
||||
if (overlayHost) {
|
||||
overlayHost.clearSource(PERIMETER_OVERLAY_SOURCE_ID);
|
||||
overlayHost.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
|
||||
}
|
||||
}
|
||||
|
||||
const layer = {
|
||||
id: 'fire-perimeters',
|
||||
name: 'Fire Perimeters',
|
||||
icon: '🔥',
|
||||
source: 'NIFC WFIGS',
|
||||
updateInterval: 300000,
|
||||
|
||||
init(viewer) {
|
||||
if (_viewer)
|
||||
throw new Error('Fire perimeter layer is already initialized');
|
||||
_viewer = viewer;
|
||||
_dataSource = new Cesium.CustomDataSource('fire-perimeters');
|
||||
_dataSource.show = false;
|
||||
viewer.dataSources.add(_dataSource);
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_enabled = false;
|
||||
console.log('[Data:FirePerimeters] Initialized');
|
||||
},
|
||||
|
||||
enable() {
|
||||
_enabled = true;
|
||||
if (_dataSource) _dataSource.show = true;
|
||||
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
|
||||
installClickHandler();
|
||||
},
|
||||
|
||||
disable() {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) _dataSource.show = false;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
},
|
||||
|
||||
async update() {
|
||||
if (!_enabled || !_dataSource) return false;
|
||||
_request?.abort();
|
||||
const request = new AbortController();
|
||||
_request = request;
|
||||
refreshInciwebIndex(request.signal);
|
||||
try {
|
||||
const rows = await source.getSnapshot({ signal: request.signal });
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
|
||||
// Incident facts may change independently of the polygon's source clock.
|
||||
const signature = JSON.stringify(
|
||||
rows
|
||||
.map(({ polygons, ...facts }) => facts)
|
||||
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
abortLinkVerification();
|
||||
const nextEntities = [];
|
||||
_rowById.clear();
|
||||
for (const row of rows) {
|
||||
const color = containmentColor(row.containedPct);
|
||||
for (const [index, rings] of row.polygons.entries()) {
|
||||
const [outer, ...holes] = rings;
|
||||
const outerPositions = ringPositions(outer);
|
||||
nextEntities.push(
|
||||
new Cesium.Entity({
|
||||
id: `fire-perimeter:${row.stableId}:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
outerPositions,
|
||||
holes.map(
|
||||
(hole) =>
|
||||
new Cesium.PolygonHierarchy(ringPositions(hole)),
|
||||
),
|
||||
),
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.25),
|
||||
),
|
||||
},
|
||||
// The fire line: ground-clamped outline (entity polygons cannot
|
||||
// outline clamped geometry themselves).
|
||||
polyline: {
|
||||
positions: outerPositions,
|
||||
clampToGround: true,
|
||||
width: 2,
|
||||
material: new Cesium.ColorMaterialProperty(
|
||||
color.withAlpha(0.9),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Keep a geometry-free projection: the coordinate arrays would
|
||||
// otherwise be retained for the layer's lifetime while only the
|
||||
// anchor is ever read again.
|
||||
const { polygons, ...facts } = row;
|
||||
_rowById.set(row.stableId, {
|
||||
...facts,
|
||||
anchor: perimeterAnchorDegrees(polygons),
|
||||
});
|
||||
}
|
||||
|
||||
_dataSource.entities.removeAll();
|
||||
for (const entity of nextEntities) _dataSource.entities.add(entity);
|
||||
_snapshotSignature = signature;
|
||||
// Refresh (or drop) the selected incident's card against the new feed.
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = Date.now();
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||
_lastError = e?.message || 'Perimeter source unavailable';
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer = _viewer) {
|
||||
_request?.abort();
|
||||
_request = null;
|
||||
removeClickHandler();
|
||||
clearSelection();
|
||||
_rowById.clear();
|
||||
_snapshotSignature = null;
|
||||
_inciwebIndex = [];
|
||||
_indexFetchedAt = 0;
|
||||
_linkVerdicts.clear();
|
||||
_viewer = null;
|
||||
_enabled = false;
|
||||
if (_dataSource) {
|
||||
viewer.dataSources.remove(_dataSource, true);
|
||||
_dataSource = null;
|
||||
}
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
},
|
||||
|
||||
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!_dataSource || !_dataSource.show) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const result = [];
|
||||
for (const row of _rowById.values()) {
|
||||
if (result.length >= limit) break;
|
||||
const { anchor, stableId, ...facts } = row;
|
||||
result.push({
|
||||
id: stableId,
|
||||
...facts,
|
||||
lat: anchor.lat,
|
||||
lon: anchor.lon,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
getRowControls() {
|
||||
const bands = [
|
||||
{ label: 'Not contained or unknown', color: containmentAccent(0) },
|
||||
{ label: 'Under 50% contained', color: containmentAccent(1) },
|
||||
{ label: '50–99% contained', color: containmentAccent(50) },
|
||||
{ label: 'Fully contained', color: containmentAccent(100) },
|
||||
];
|
||||
return {
|
||||
chips: [],
|
||||
legend: bands.map((band, index) => ({
|
||||
...band,
|
||||
count: [..._rowById.values()].filter(
|
||||
(row) => containmentAccent(row.containedPct) === band.color,
|
||||
).length,
|
||||
...(index === 0
|
||||
? {
|
||||
blurb:
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createFirePerimetersLayer } from './index.js';
|
||||
|
||||
function harness(
|
||||
source,
|
||||
{
|
||||
pick = () => null,
|
||||
inciwebIndex = null,
|
||||
cardHit = () => null,
|
||||
publication = async () => ({
|
||||
createdMs: 1757000000000,
|
||||
changedMs: 1757900000000,
|
||||
}),
|
||||
} = {},
|
||||
) {
|
||||
const sources = [];
|
||||
const overlay = { entries: new Map(), visible: null };
|
||||
const clicks = { handler: null, destroyed: 0 };
|
||||
const owners = new Map();
|
||||
const opened = [];
|
||||
const viewer = {
|
||||
scene: { pick },
|
||||
dataSources: {
|
||||
add(value) {
|
||||
sources.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
sources.splice(sources.indexOf(value), 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
const layer = createFirePerimetersLayer({
|
||||
source,
|
||||
overlayHost: {
|
||||
setEntries(sourceId, entries) {
|
||||
overlay.entries.set(sourceId, entries);
|
||||
},
|
||||
setVisible(sourceId, visible) {
|
||||
overlay.visible = visible;
|
||||
},
|
||||
clearSource(sourceId) {
|
||||
overlay.entries.delete(sourceId);
|
||||
},
|
||||
hitTest: (x, y, options) => cardHit(x, y, options),
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction(callback) {
|
||||
clicks.handler = callback;
|
||||
},
|
||||
destroy() {
|
||||
clicks.destroyed += 1;
|
||||
clicks.handler = null;
|
||||
},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: (picked) => picked?.id ?? null,
|
||||
isOwnedByOtherLayer: (layerId, pickedId) =>
|
||||
String(pickedId).startsWith('other-layer:'),
|
||||
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
|
||||
unregisterPickOwner: (layerId) => owners.delete(layerId),
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
inciwebSource: inciwebIndex
|
||||
? { getIndex: async () => inciwebIndex }
|
||||
: { getIndex: async () => [] },
|
||||
inciwebPublications: { getPublication: publication },
|
||||
openExternal: (url) => opened.push(url),
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
return { layer, viewer, sources, overlay, clicks, owners, opened };
|
||||
}
|
||||
|
||||
const ring = [
|
||||
[-108.1, 35.2],
|
||||
[-108.0, 35.2],
|
||||
[-108.0, 35.3],
|
||||
[-108.1, 35.2],
|
||||
];
|
||||
const row = {
|
||||
stableId: '2026-NMGNF-000123',
|
||||
name: 'Fixture Fire',
|
||||
acres: 512.5,
|
||||
containedPct: 40,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: 1757900000000,
|
||||
updatedTime: 1757950000000,
|
||||
cause: 'Natural',
|
||||
behavior: 'Active',
|
||||
personnel: 380,
|
||||
county: 'Sandoval',
|
||||
costToDate: 4200000,
|
||||
complexity: 'Type 3 Incident',
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
polygons: [[ring]],
|
||||
};
|
||||
|
||||
test('each perimeter polygon renders as one filled entity with its fire line', async () => {
|
||||
const h = harness({
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
|
||||
],
|
||||
});
|
||||
assert.equal(await h.layer.update(h.viewer), true);
|
||||
const entities = h.sources[0].entities.values;
|
||||
assert.equal(entities.length, 3);
|
||||
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
|
||||
assert.ok(entities[0].polygon, 'perimeter fill missing');
|
||||
assert.ok(entities[0].polyline, 'fire line missing');
|
||||
assert.equal(entities[1].id, 'fire-perimeter:other:0');
|
||||
assert.equal(entities[2].id, 'fire-perimeter:other:1');
|
||||
assert.equal(h.layer.getStats().count, 2);
|
||||
});
|
||||
|
||||
test('late refresh cannot publish after disable or destroy', async () => {
|
||||
for (const action of ['disable', 'destroy']) {
|
||||
let resolve, signal;
|
||||
const h = harness({
|
||||
getSnapshot(options) {
|
||||
signal = options.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
});
|
||||
const pending = h.layer.update(h.viewer);
|
||||
h.layer[action](h.viewer);
|
||||
assert.equal(signal.aborted, true);
|
||||
if (action === 'disable') h.layer.enable(h.viewer);
|
||||
resolve([row]);
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
h.layer.destroy(h.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking a perimeter publishes its incident card; empty space clears it', async () => {
|
||||
let pickResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => pickResult,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
|
||||
// Deliberately NOT registered as a pick owner: sibling layers' click
|
||||
// handlers yield to owned picks before their own card hit-tests, so
|
||||
// claiming these large ground polygons would make FIRMS/vessel/CCTV
|
||||
// overlay cards inert anywhere over a perimeter.
|
||||
assert.equal(h.owners.size, 0);
|
||||
|
||||
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
|
||||
assert.equal(entries[0].title, 'FIRE · Fixture Fire');
|
||||
assert.ok(entries[0].position, 'card must carry a world anchor');
|
||||
|
||||
pickResult = { id: 'other-layer:aircraft-1' };
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(
|
||||
h.overlay.entries.get('fire-perimeters')?.length,
|
||||
1,
|
||||
'sibling-layer picks must not clear the selection',
|
||||
);
|
||||
|
||||
pickResult = null;
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('disable removes the click handler, pick ownership, and any card', async () => {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
h.layer.disable(h.viewer);
|
||||
assert.equal(h.clicks.destroyed, 1);
|
||||
assert.equal(h.owners.has('fire-perimeters'), false);
|
||||
assert.equal(h.overlay.entries.has('fire-perimeters'), false);
|
||||
});
|
||||
|
||||
test('a refresh that drops the selected incident also drops its card', async () => {
|
||||
let rows = [row];
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => rows },
|
||||
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||
rows = [{ ...row, stableId: 'different' }];
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
|
||||
});
|
||||
|
||||
test('a matched incident card carries the InciWeb line and click-through', async () => {
|
||||
const inciwebIndex = [
|
||||
{
|
||||
incident_id: '329300',
|
||||
tau: 'NMGNF Fixture Fire',
|
||||
incident_title: 'Fixture Fire',
|
||||
},
|
||||
];
|
||||
let cardHitResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [{ ...row, name: 'Fixture' }] },
|
||||
{
|
||||
pick: () => null,
|
||||
inciwebIndex,
|
||||
cardHit: () => cardHitResult,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
// Select via a perimeter pick.
|
||||
h.viewer.scene.pick = () => ({
|
||||
id: 'fire-perimeter:2026-NMGNF-000123:0',
|
||||
});
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
// The card publishes immediately; the link line lands once the
|
||||
// publication's currency is validated.
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
|
||||
|
||||
// Clicking the card itself opens the InciWeb page.
|
||||
cardHitResult = { entryId: entries[0].id };
|
||||
h.viewer.scene.pick = () => null;
|
||||
h.clicks.handler({ position: { x: 12, y: 12 } });
|
||||
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
|
||||
assert.equal(
|
||||
h.overlay.entries.get('fire-perimeters').length,
|
||||
1,
|
||||
'opening the link must not clear the selection',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unmatched incident renders no InciWeb line and card clicks stay inert', async () => {
|
||||
let cardHitResult = null;
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
|
||||
inciwebIndex: [],
|
||||
cardHit: () => cardHitResult,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(
|
||||
entries[0].details.some((line) => line.includes('InciWeb')),
|
||||
false,
|
||||
);
|
||||
cardHitResult = { entryId: entries[0].id };
|
||||
h.viewer.scene.pick = () => null;
|
||||
h.clicks.handler({ position: { x: 12, y: 12 } });
|
||||
assert.deepEqual(h.opened, []);
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||
});
|
||||
|
||||
test('a complex member resolves to its complex page from the catalog', async () => {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
|
||||
inciwebIndex: [
|
||||
{
|
||||
// Same state as the member fire: the state filter applies to
|
||||
// complex fallbacks too.
|
||||
incident_id: '328923',
|
||||
tau: 'NMPRD Rowe Creek Complex',
|
||||
incident_title: 'Rowe Creek Complex',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
|
||||
});
|
||||
|
||||
test('a stale or unverifiable publication yields no link', async () => {
|
||||
const inciwebIndex = [
|
||||
{
|
||||
incident_id: '100',
|
||||
tau: 'NMGNF Fixture Fire',
|
||||
incident_title: 'Fixture Fire',
|
||||
},
|
||||
];
|
||||
for (const publication of [
|
||||
// Created two years before this fire's discovery: an archived page.
|
||||
async () => ({
|
||||
createdMs: 1757900000000 - 700 * 86400000,
|
||||
changedMs: null,
|
||||
}),
|
||||
// Validation endpoint down: fail closed, no link.
|
||||
async () => {
|
||||
throw new Error('InciWeb HTTP 503');
|
||||
},
|
||||
]) {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => [row] },
|
||||
{
|
||||
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
|
||||
inciwebIndex,
|
||||
publication,
|
||||
},
|
||||
);
|
||||
await h.layer.update(h.viewer);
|
||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(
|
||||
entries[0].details.some((line) => line.includes('InciWeb')),
|
||||
false,
|
||||
);
|
||||
assert.equal(entries[0].interactive, false);
|
||||
h.layer.destroy(h.viewer);
|
||||
}
|
||||
});
|
||||
|
||||
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
|
||||
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
|
||||
// Replace the index source with one that never resolves.
|
||||
h.layer.destroy(h.viewer);
|
||||
const hanging = harness({ getSnapshot: async () => [row] });
|
||||
hanging.layer.destroy(hanging.viewer);
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
scene: { pick: () => null },
|
||||
dataSources: {
|
||||
add: (v) => sources.push(v),
|
||||
remove: (v) => sources.splice(sources.indexOf(v), 1),
|
||||
},
|
||||
};
|
||||
const layer = createFirePerimetersLayer({
|
||||
source: { getSnapshot: async () => [row] },
|
||||
overlayHost: {
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
hitTest: () => null,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: () => null,
|
||||
isOwnedByOtherLayer: () => false,
|
||||
registerPickOwner() {},
|
||||
unregisterPickOwner() {},
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
inciwebSource: { getIndex: () => new Promise(() => {}) },
|
||||
inciwebPublications: {
|
||||
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
|
||||
},
|
||||
openExternal: () => {},
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
assert.equal(await layer.update(viewer), true);
|
||||
assert.equal(layer.getStats().count, 1);
|
||||
layer.destroy(viewer);
|
||||
});
|
||||
|
||||
test('an InciWeb outage never breaks the perimeter refresh', async () => {
|
||||
const h = harness({ getSnapshot: async () => [row] });
|
||||
const failing = createFirePerimetersLayer({
|
||||
source: { getSnapshot: async () => [row] },
|
||||
overlayHost: {
|
||||
setEntries() {},
|
||||
setVisible() {},
|
||||
clearSource() {},
|
||||
hitTest: () => null,
|
||||
},
|
||||
screenSpaceEventHandlerFactory: () => ({
|
||||
setInputAction() {},
|
||||
destroy() {},
|
||||
}),
|
||||
picking: {
|
||||
resolvePickId: () => null,
|
||||
isOwnedByOtherLayer: () => false,
|
||||
registerPickOwner() {},
|
||||
unregisterPickOwner() {},
|
||||
},
|
||||
pointer: { isPointerFree: () => true },
|
||||
inciwebSource: {
|
||||
getIndex: async () => {
|
||||
throw new Error('InciWeb HTTP 503');
|
||||
},
|
||||
},
|
||||
openExternal: () => {},
|
||||
});
|
||||
const viewer = {
|
||||
scene: { pick: () => null },
|
||||
dataSources: { add() {}, remove() {} },
|
||||
};
|
||||
failing.init(viewer);
|
||||
failing.enable(viewer);
|
||||
assert.equal(await failing.update(viewer), true);
|
||||
assert.equal(failing.getStats().count, 1);
|
||||
failing.destroy(viewer);
|
||||
h.layer.destroy(h.viewer);
|
||||
});
|
||||
|
||||
test('analyst records expose incident facts without geometry payloads', async () => {
|
||||
const h = harness({ getSnapshot: async () => [row] });
|
||||
await h.layer.update(h.viewer);
|
||||
const records = h.layer.getAnalystRecords();
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].name, 'Fixture Fire');
|
||||
assert.equal(records[0].acres, 512.5);
|
||||
assert.equal(records[0].containedPct, 40);
|
||||
assert.equal(records[0].state, 'US-NM');
|
||||
assert.equal(records[0].cause, 'Natural');
|
||||
assert.equal(records[0].behavior, 'Active');
|
||||
assert.equal(records[0].personnel, 380);
|
||||
assert.equal(records[0].county, 'Sandoval');
|
||||
assert.equal(records[0].costToDate, 4200000);
|
||||
assert.equal(records[0].complexity, 'Type 3 Incident');
|
||||
// Spatial scoping in the analyst engine requires coordinates.
|
||||
assert.ok(Math.abs(records[0].lat - 35.2333) < 0.01);
|
||||
assert.ok(Math.abs(records[0].lon - -108.0333) < 0.01);
|
||||
assert.equal('polygons' in records[0], false);
|
||||
});
|
||||
|
||||
test('unchanged snapshots retain entity identity even if source ordering changes', async () => {
|
||||
let rows = [row, { ...row, stableId: 'other' }];
|
||||
const h = harness({ getSnapshot: async () => rows });
|
||||
await h.layer.update();
|
||||
const original = [...h.sources[0].entities.values];
|
||||
let removals = 0;
|
||||
const entities = h.sources[0].entities;
|
||||
const removeAll = entities.removeAll.bind(entities);
|
||||
entities.removeAll = () => {
|
||||
removals++;
|
||||
removeAll();
|
||||
};
|
||||
rows = [...rows].reverse();
|
||||
await h.layer.update();
|
||||
assert.equal(removals, 0);
|
||||
assert.equal(entities.values[0], original[0]);
|
||||
rows = rows.map((value) => ({
|
||||
...value,
|
||||
updatedTime: value.updatedTime + 1,
|
||||
}));
|
||||
await h.layer.update();
|
||||
assert.equal(removals, 1);
|
||||
assert.notEqual(entities.values[0], original[0]);
|
||||
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
|
||||
await h.layer.update();
|
||||
assert.equal(
|
||||
removals,
|
||||
2,
|
||||
'incident facts can change without a new polygon timestamp',
|
||||
);
|
||||
assert.equal(h.layer.getRowControls().legend[3].count, 2);
|
||||
rows = [];
|
||||
await h.layer.update();
|
||||
assert.equal(entities.values.length, 0);
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('containment legend uses the existing thresholds, colours and incident counts', async () => {
|
||||
const values = [null, -1, 0, 0.5, 49.9, 50, 99.9, 100, 101];
|
||||
const h = harness({
|
||||
getSnapshot: async () =>
|
||||
values.map((containedPct, index) => ({
|
||||
...row,
|
||||
stableId: String(index),
|
||||
containedPct,
|
||||
})),
|
||||
});
|
||||
await h.layer.update();
|
||||
const controls = h.layer.getRowControls();
|
||||
assert.deepEqual(controls.chips, []);
|
||||
assert.deepEqual(
|
||||
controls.legend.map(({ color, count }) => [color, count]),
|
||||
[
|
||||
['#ff3b30', 3],
|
||||
['#ff7a00', 2],
|
||||
['#ffb300', 2],
|
||||
['#8bc34a', 2],
|
||||
],
|
||||
);
|
||||
assert.ok(
|
||||
controls.legend.every(
|
||||
({ label }) => typeof label === 'string' && label.length,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
controls.legend[0].blurb,
|
||||
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
|
||||
);
|
||||
h.layer.destroy();
|
||||
assert.deepEqual(
|
||||
h.layer.getRowControls().legend.map(({ count }) => count),
|
||||
[0, 0, 0, 0],
|
||||
);
|
||||
});
|
||||
|
||||
for (const action of ['disable', 'destroy', 'selection']) {
|
||||
test(`incident-link verification aborts on ${action} and ignores late completion`, async () => {
|
||||
let finish, signal;
|
||||
let pick = { id: `fire-perimeter:${row.stableId}:0` };
|
||||
const h = harness(
|
||||
{
|
||||
getSnapshot: async () => [
|
||||
row,
|
||||
{ ...row, stableId: 'other', name: 'Other Fire' },
|
||||
],
|
||||
},
|
||||
{
|
||||
pick: () => pick,
|
||||
inciwebIndex: [
|
||||
{ incident_id: '123', incident_title: row.name, tau: 'nm' },
|
||||
],
|
||||
publication: (_id, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
await h.layer.update();
|
||||
h.clicks.handler({ position: { x: 1, y: 1 } });
|
||||
assert.equal(signal.aborted, false);
|
||||
if (action === 'selection') {
|
||||
pick = { id: 'fire-perimeter:other:0' };
|
||||
h.clicks.handler({ position: { x: 1, y: 1 } });
|
||||
} else h.layer[action]();
|
||||
assert.equal(signal.aborted, true);
|
||||
const entries = h.overlay.entries.get('fire-perimeters');
|
||||
finish({ createdMs: row.discoveredTime, changedMs: row.updatedTime });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(h.overlay.entries.get('fire-perimeters'), entries);
|
||||
if (action !== 'destroy') h.layer.destroy();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Normalize a WFIGS feed, skipping individually invalid features.
|
||||
* Only a payload that is not a feature collection at all rejects the whole
|
||||
* snapshot: a single degenerate incident (ArcGIS emits geometry:null when
|
||||
* maxAllowableOffset generalizes a tiny polygon away) must never blank the
|
||||
* layer — a failed first update makes the lifecycle disable it entirely.
|
||||
*/
|
||||
|
||||
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
|
||||
const textOrNull = (value) => (typeof value === 'string' ? value : null);
|
||||
|
||||
function validRing(ring) {
|
||||
if (!Array.isArray(ring) || ring.length < 4) return false;
|
||||
for (const position of ring) {
|
||||
if (!Array.isArray(position) || position.length < 2) return false;
|
||||
const [lon, lat] = position;
|
||||
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
|
||||
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
|
||||
* array of rings). Returns null on malformed geometry, [] when empty. */
|
||||
function normalizePolygons(geometry) {
|
||||
if (!geometry || typeof geometry !== 'object') return null;
|
||||
let polygons;
|
||||
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
|
||||
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
|
||||
else return null;
|
||||
if (!Array.isArray(polygons)) return null;
|
||||
const result = [];
|
||||
for (const rings of polygons) {
|
||||
if (!Array.isArray(rings)) return null;
|
||||
if (!rings.length) continue;
|
||||
if (!rings.every(validRing)) return null;
|
||||
result.push(rings);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function normalizeFirePerimeterSnapshot(geojson) {
|
||||
if (!Array.isArray(geojson?.features)) return null;
|
||||
const rows = [];
|
||||
const ids = new Set();
|
||||
for (const feature of geojson.features) {
|
||||
const properties = feature?.properties;
|
||||
if (
|
||||
!properties ||
|
||||
typeof properties !== 'object' ||
|
||||
Array.isArray(properties)
|
||||
)
|
||||
continue;
|
||||
const polygons = normalizePolygons(feature.geometry);
|
||||
if (polygons === null || !polygons.length) continue;
|
||||
const uniqueId = properties.attr_UniqueFireIdentifier;
|
||||
const stableId =
|
||||
typeof uniqueId === 'string' && uniqueId !== ''
|
||||
? uniqueId
|
||||
: feature.id == null || feature.id === ''
|
||||
? null
|
||||
: String(feature.id);
|
||||
if (stableId == null || ids.has(stableId)) continue;
|
||||
ids.add(stableId);
|
||||
rows.push({
|
||||
stableId,
|
||||
name:
|
||||
typeof properties.poly_IncidentName === 'string'
|
||||
? properties.poly_IncidentName
|
||||
: null,
|
||||
acres: finiteOrNull(properties.attr_IncidentSize),
|
||||
containedPct: finiteOrNull(properties.attr_PercentContained),
|
||||
state:
|
||||
typeof properties.attr_POOState === 'string'
|
||||
? properties.attr_POOState
|
||||
: null,
|
||||
category:
|
||||
typeof properties.attr_IncidentTypeCategory === 'string'
|
||||
? properties.attr_IncidentTypeCategory
|
||||
: null,
|
||||
discoveredTime: finiteOrNull(properties.attr_FireDiscoveryDateTime),
|
||||
updatedTime: finiteOrNull(properties.poly_DateCurrent),
|
||||
cause: textOrNull(properties.attr_FireCause),
|
||||
behavior: textOrNull(properties.attr_FireBehaviorGeneral),
|
||||
personnel: finiteOrNull(properties.attr_TotalIncidentPersonnel),
|
||||
county: textOrNull(properties.attr_POOCounty),
|
||||
costToDate: finiteOrNull(properties.attr_EstimatedCostToDate),
|
||||
complexity: textOrNull(properties.attr_IncidentComplexityLevel),
|
||||
complexName: textOrNull(properties.attr_CpxName),
|
||||
polygons,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
|
||||
const ring = [
|
||||
[-108.1, 35.2],
|
||||
[-108.0, 35.2],
|
||||
[-108.0, 35.3],
|
||||
[-108.1, 35.2],
|
||||
];
|
||||
|
||||
function feature(overrides = {}) {
|
||||
return {
|
||||
id: 1,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: {
|
||||
poly_IncidentName: 'Fixture Fire',
|
||||
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
|
||||
attr_IncidentSize: 512.5,
|
||||
attr_PercentContained: 40,
|
||||
attr_POOState: 'US-NM',
|
||||
attr_IncidentTypeCategory: 'WF',
|
||||
attr_FireDiscoveryDateTime: 1757900000000,
|
||||
poly_DateCurrent: 1757950000000,
|
||||
attr_FireCause: 'Natural',
|
||||
attr_FireBehaviorGeneral: 'Active',
|
||||
attr_TotalIncidentPersonnel: 380,
|
||||
attr_POOCounty: 'Sandoval',
|
||||
attr_EstimatedCostToDate: 4200000,
|
||||
attr_IncidentComplexityLevel: 'Type 3 Incident',
|
||||
attr_CpxName: 'ROWE CREEK COMPLEX',
|
||||
...overrides.properties,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('a valid snapshot yields one row per incident with polygons preserved', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature(),
|
||||
feature({
|
||||
id: 2,
|
||||
geometry: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [[ring], [ring]],
|
||||
},
|
||||
properties: { attr_UniqueFireIdentifier: '2026-AZASF-000456' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows[0], {
|
||||
stableId: '2026-NMGNF-000123',
|
||||
name: 'Fixture Fire',
|
||||
acres: 512.5,
|
||||
containedPct: 40,
|
||||
state: 'US-NM',
|
||||
category: 'WF',
|
||||
discoveredTime: 1757900000000,
|
||||
updatedTime: 1757950000000,
|
||||
cause: 'Natural',
|
||||
behavior: 'Active',
|
||||
personnel: 380,
|
||||
county: 'Sandoval',
|
||||
costToDate: 4200000,
|
||||
complexity: 'Type 3 Incident',
|
||||
complexName: 'ROWE CREEK COMPLEX',
|
||||
polygons: [[ring]],
|
||||
});
|
||||
assert.equal(rows[1].polygons.length, 2);
|
||||
});
|
||||
|
||||
test('a missing unique fire identifier falls back to the feature id', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [feature({ properties: { attr_UniqueFireIdentifier: null } })],
|
||||
});
|
||||
assert.equal(rows[0].stableId, '1');
|
||||
});
|
||||
|
||||
test('optional attributes normalize to null rather than leaking undefined', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature({
|
||||
properties: {
|
||||
poly_IncidentName: null,
|
||||
attr_IncidentSize: null,
|
||||
attr_PercentContained: null,
|
||||
attr_POOState: null,
|
||||
attr_IncidentTypeCategory: null,
|
||||
attr_FireDiscoveryDateTime: null,
|
||||
poly_DateCurrent: null,
|
||||
attr_FireCause: null,
|
||||
attr_FireBehaviorGeneral: null,
|
||||
attr_TotalIncidentPersonnel: null,
|
||||
attr_POOCounty: null,
|
||||
attr_EstimatedCostToDate: null,
|
||||
attr_IncidentComplexityLevel: null,
|
||||
attr_CpxName: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: rows[0].name,
|
||||
acres: rows[0].acres,
|
||||
containedPct: rows[0].containedPct,
|
||||
state: rows[0].state,
|
||||
category: rows[0].category,
|
||||
discoveredTime: rows[0].discoveredTime,
|
||||
updatedTime: rows[0].updatedTime,
|
||||
cause: rows[0].cause,
|
||||
behavior: rows[0].behavior,
|
||||
personnel: rows[0].personnel,
|
||||
county: rows[0].county,
|
||||
costToDate: rows[0].costToDate,
|
||||
complexity: rows[0].complexity,
|
||||
complexName: rows[0].complexName,
|
||||
},
|
||||
{
|
||||
name: null,
|
||||
acres: null,
|
||||
containedPct: null,
|
||||
state: null,
|
||||
category: null,
|
||||
discoveredTime: null,
|
||||
updatedTime: null,
|
||||
cause: null,
|
||||
behavior: null,
|
||||
personnel: null,
|
||||
county: null,
|
||||
costToDate: null,
|
||||
complexity: null,
|
||||
complexName: null,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('a feed that is not a feature collection is rejected as a whole', () => {
|
||||
for (const payload of [null, {}, { features: null }, { features: {} }]) {
|
||||
assert.equal(normalizeFirePerimeterSnapshot(payload), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('an invalid feature is skipped so one bad incident cannot blank the layer', () => {
|
||||
const good = feature({
|
||||
id: 2,
|
||||
properties: { attr_UniqueFireIdentifier: 'good' },
|
||||
});
|
||||
for (const bad of [
|
||||
feature({ geometry: null }),
|
||||
feature({ geometry: { type: 'Point', coordinates: [0, 1] } }),
|
||||
feature({
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[[Infinity, 35.2], ...ring.slice(1)]],
|
||||
},
|
||||
}),
|
||||
feature({
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [[[-200, 35.2], ...ring.slice(1)]],
|
||||
},
|
||||
}),
|
||||
feature({ geometry: { type: 'Polygon', coordinates: [ring.slice(0, 2)] } }),
|
||||
feature({ properties: null }),
|
||||
feature({ id: null, properties: { attr_UniqueFireIdentifier: null } }),
|
||||
]) {
|
||||
const rows = normalizeFirePerimeterSnapshot({ features: [bad, good] });
|
||||
assert.equal(rows.length, 1, 'bad feature must be skipped, not fatal');
|
||||
assert.equal(rows[0].stableId, 'good');
|
||||
}
|
||||
});
|
||||
|
||||
test('a duplicate incident id keeps the first occurrence and skips the rest', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature(),
|
||||
feature({
|
||||
properties: {
|
||||
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
|
||||
attr_IncidentSize: 999,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].acres, 512.5);
|
||||
});
|
||||
|
||||
test('a perimeter with no rings is skipped rather than rendered empty', () => {
|
||||
const rows = normalizeFirePerimeterSnapshot({
|
||||
features: [
|
||||
feature({ geometry: { type: 'Polygon', coordinates: [] } }),
|
||||
feature({ id: 2, properties: { attr_UniqueFireIdentifier: 'other' } }),
|
||||
],
|
||||
});
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].stableId, 'other');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
|
||||
export function createWfigsPerimeterSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error('Malformed perimeter snapshot');
|
||||
return payload.rows;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWfigsPerimeterSource } from './source.js';
|
||||
import { normalizeFirePerimeterSnapshot } from './records.js';
|
||||
import { firePerimetersProxy } from '../../../server/providers/firePerimeters.js';
|
||||
|
||||
const ring = [
|
||||
[-108.1, 35.2],
|
||||
[-108.0, 35.2],
|
||||
[-108.0, 35.3],
|
||||
[-108.1, 35.2],
|
||||
];
|
||||
const validPayload = {
|
||||
features: [
|
||||
{
|
||||
id: 1,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: { attr_UniqueFireIdentifier: '2026-NMGNF-000123' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('a successful response yields normalized perimeter rows', async () => {
|
||||
let requested;
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested = String(url);
|
||||
return Response.json({
|
||||
rows: normalizeFirePerimeterSnapshot(validPayload),
|
||||
});
|
||||
},
|
||||
});
|
||||
const rows = await source.getSnapshot();
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].stableId, '2026-NMGNF-000123');
|
||||
assert.equal(requested, '/api/fire-perimeters');
|
||||
});
|
||||
|
||||
test('a truncated response pages until the feed is complete', async () => {
|
||||
const pageRing = (id) => ({
|
||||
id,
|
||||
geometry: { type: 'Polygon', coordinates: [ring] },
|
||||
properties: { attr_UniqueFireIdentifier: `fire-${id}` },
|
||||
});
|
||||
const requests = [];
|
||||
let handler;
|
||||
const plugin = firePerimetersProxy({
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
|
||||
const page = requests.length;
|
||||
return Response.json({
|
||||
features: [pageRing(page)],
|
||||
properties: { exceededTransferLimit: page < 3 },
|
||||
});
|
||||
},
|
||||
});
|
||||
plugin.configureServer({
|
||||
middlewares: {
|
||||
use: (_path, callback) => {
|
||||
handler = callback;
|
||||
},
|
||||
},
|
||||
});
|
||||
let payload;
|
||||
await handler(
|
||||
{ url: '/', method: 'GET' },
|
||||
{
|
||||
writeHead(status) {
|
||||
assert.equal(status, 200);
|
||||
},
|
||||
end(body) {
|
||||
payload = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
const rows = payload.rows;
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.stableId),
|
||||
['fire-1', 'fire-2', 'fire-3'],
|
||||
);
|
||||
assert.deepEqual(requests, [null, '1', '2']);
|
||||
});
|
||||
|
||||
test('an upstream failure surfaces its HTTP status', async () => {
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => ({ ok: false, status: 503 }),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /WFIGS HTTP 503/);
|
||||
});
|
||||
|
||||
test('a malformed successful response is never accepted as an empty snapshot', async () => {
|
||||
for (const payload of [{}, { rows: null }, { rows: {} }]) {
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json(payload),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed perimeter snapshot/);
|
||||
}
|
||||
});
|
||||
|
||||
test('response-body completion honors cancellation without replacing records', async () => {
|
||||
const abort = new AbortController();
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
text: async () => {
|
||||
abort.abort();
|
||||
return JSON.stringify({
|
||||
rows: normalizeFirePerimeterSnapshot(validPayload),
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,21 @@ import { infraredAlpha } from './infraredAlpha.js';
|
||||
|
||||
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Apply the display transfer once to a decoded image. */
|
||||
export function processInfraredImage(image, mode, createCanvas) {
|
||||
/** Apply the display transfer once to a decoded image; `flipY` draws a
|
||||
* bottom-up image back upright. */
|
||||
export function processInfraredImage(
|
||||
image,
|
||||
mode,
|
||||
createCanvas,
|
||||
{ flipY = false } = {},
|
||||
) {
|
||||
const canvas = createCanvas();
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
|
||||
context.drawImage(image, 0, 0);
|
||||
if (flipY) context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
pixels.data.set(infraredAlpha(pixels.data, mode));
|
||||
context.putImageData(pixels, 0, 0);
|
||||
|
||||
@@ -10,8 +10,12 @@ import {
|
||||
function canvas() {
|
||||
const output = {};
|
||||
output.getContext = () => ({
|
||||
setTransform: (...matrix) => {
|
||||
output.transforms = [...(output.transforms ?? []), matrix];
|
||||
},
|
||||
drawImage: (image) => {
|
||||
output.image = image;
|
||||
output.drawnWith = output.transforms?.at(-1) ?? null;
|
||||
},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
|
||||
putImageData: (pixels) => {
|
||||
@@ -34,6 +38,14 @@ test('decoded pixels are processed once on a same-sized canvas', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('flipY draws a bottom-up image upright, then restores the transform', () => {
|
||||
const image = { width: 4, height: 8 };
|
||||
assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null);
|
||||
const output = processInfraredImage(image, 'full', canvas, { flipY: true });
|
||||
assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]);
|
||||
assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]);
|
||||
});
|
||||
|
||||
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
|
||||
const controller = new AbortController();
|
||||
let closes = 0,
|
||||
|
||||
@@ -381,8 +381,14 @@ function createGlobeRendering({
|
||||
if (request) frame.requests.add(request);
|
||||
return Promise.resolve(result)
|
||||
.then((image) => {
|
||||
// Cesium decodes tiles as ImageBitmaps already flipped, since WebGL
|
||||
// ignores UNPACK_FLIP_Y for them; a canvas upload flips again.
|
||||
if (!frame.closed && snapshot.product === 'clouds-regional')
|
||||
image = processInfraredImage(image, infrared, createCanvas);
|
||||
image = processInfraredImage(image, infrared, createCanvas, {
|
||||
flipY:
|
||||
typeof ImageBitmap !== 'undefined' &&
|
||||
image instanceof ImageBitmap,
|
||||
});
|
||||
frame.retries.delete(tileKey);
|
||||
frame.loaded++;
|
||||
return image;
|
||||
|
||||
@@ -71,6 +71,8 @@ export function createShellCesium({ maximumTextureSize = 0 } = {}) {
|
||||
GeometryInstance,
|
||||
BlendingState: Cesium.BlendingState,
|
||||
Cartesian4: Cesium.Cartesian4,
|
||||
Ellipsoid: Cesium.Ellipsoid,
|
||||
Matrix4: Cesium.Matrix4,
|
||||
Math: Cesium.Math,
|
||||
Rectangle: Cesium.Rectangle,
|
||||
ContextLimits: { maximumTextureSize },
|
||||
|
||||
@@ -25,6 +25,12 @@ const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
|
||||
const DETAIL_MIN_WIDTH = 6;
|
||||
const DETAIL_GRID = 0.5;
|
||||
const shellHeights = new WeakMap();
|
||||
// From far away Google's coarse 3D tiles rise tens of kilometres above the
|
||||
// ground and hide the shells, so the shells rise with the camera: 4 m per
|
||||
// kilometre of camera height, at most 60 km, in 500 m steps.
|
||||
const LIFT_PER_METRE = 0.004;
|
||||
const MAX_LIFT = 60_000;
|
||||
const LIFT_STEP = 500;
|
||||
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
|
||||
// draw nothing until the full-extent image has arrived, and sample the detail
|
||||
// image only once it has arrived. The window is west, south, east, north in the
|
||||
@@ -71,6 +77,13 @@ const sameEdges = (a, b) =>
|
||||
a.east === b.east &&
|
||||
a.north === b.north;
|
||||
|
||||
/** Metres every shell rises for a camera this high above the ellipsoid. */
|
||||
export function shellLift(cameraHeight) {
|
||||
if (!(cameraHeight > 0)) return 0;
|
||||
const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE);
|
||||
return Math.round(lift / LIFT_STEP) * LIFT_STEP;
|
||||
}
|
||||
|
||||
/** Keep weather shells first in the primitive collection, in ascending height:
|
||||
* higher shells draw over lower ones and other opaque content draws over both. */
|
||||
export function orderWeatherShells(primitives, primitive, height) {
|
||||
@@ -135,6 +148,17 @@ export function createShellSurface({
|
||||
});
|
||||
primitives.add(primitive);
|
||||
orderWeatherShells(primitives, primitive, height);
|
||||
// A uniform scale about the Earth's centre lifts every shell alike and keeps
|
||||
// their order.
|
||||
let lift = 0;
|
||||
const offLift = scene.preRender?.addEventListener(() => {
|
||||
const next = shellLift(scene.camera?.positionCartographic?.height);
|
||||
if (next === lift) return;
|
||||
lift = next;
|
||||
primitive.modelMatrix = cesium.Matrix4.fromUniformScale(
|
||||
1 + lift / cesium.Ellipsoid.WGS84.maximumRadius,
|
||||
);
|
||||
});
|
||||
let image = null;
|
||||
let frames = 0;
|
||||
// The detail image, its own upload count and a window waiting for it to draw.
|
||||
@@ -265,6 +289,7 @@ export function createShellSurface({
|
||||
destroyed = true;
|
||||
offRender?.();
|
||||
offRender = null;
|
||||
offLift?.();
|
||||
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
|
||||
primitives.remove(primitive);
|
||||
if (!primitive.isDestroyed()) primitive.destroy();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createWeatherShell,
|
||||
detailWindow,
|
||||
orderWeatherShells,
|
||||
shellLift,
|
||||
WEATHER_SHELL_CACHE_BYTES,
|
||||
WEATHER_SHELL_HEIGHTS,
|
||||
} from './shellRendering.js';
|
||||
@@ -297,6 +298,44 @@ test('a surface applies a new detail window only once its image has drawn; the s
|
||||
assert.equal(scene.postRender.size, 0);
|
||||
});
|
||||
|
||||
test('shells rise with the camera over coarse 3D tiles, in steps, up to 60 km', () => {
|
||||
assert.equal(shellLift(undefined), 0);
|
||||
assert.equal(shellLift(1_200), 0, 'city views keep the shell heights');
|
||||
assert.equal(shellLift(3_000_000), 12_000);
|
||||
assert.equal(shellLift(8_000_000), 32_000);
|
||||
assert.equal(shellLift(18_500_000), 60_000);
|
||||
const camera = { positionCartographic: { height: 1_200 } };
|
||||
const scene = {
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
camera,
|
||||
requestRender() {},
|
||||
};
|
||||
const surface = createShellSurface({
|
||||
viewer: { scene },
|
||||
cesium: Cesium,
|
||||
rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
|
||||
height: 5_800,
|
||||
});
|
||||
const primitive = scene.primitives.get(0);
|
||||
const identity = primitive.modelMatrix;
|
||||
scene.preRender.raiseEvent();
|
||||
assert.equal(primitive.modelMatrix, identity, 'no lift, no new matrix');
|
||||
camera.positionCartographic.height = 8_000_000;
|
||||
scene.preRender.raiseEvent();
|
||||
const radius = Cesium.Ellipsoid.WGS84.maximumRadius;
|
||||
assert.ok(
|
||||
Math.abs(primitive.modelMatrix[0] * radius - (radius + 32_000)) < 1e-3,
|
||||
);
|
||||
const lifted = primitive.modelMatrix;
|
||||
camera.positionCartographic.height = 8_010_000;
|
||||
scene.preRender.raiseEvent();
|
||||
assert.equal(primitive.modelMatrix, lifted, 'the same step keeps the matrix');
|
||||
surface.destroy();
|
||||
assert.equal(scene.preRender.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('real Cesium builds the two-texture material, the opaque-pass appearance and the rectangle primitive', () => {
|
||||
const scene = {
|
||||
primitives: new Cesium.PrimitiveCollection(),
|
||||
|
||||
@@ -75,9 +75,13 @@ function target(extra = {}) {
|
||||
function mockCanvas() {
|
||||
const canvas = { width: 0, height: 0 };
|
||||
canvas.getContext = () => ({
|
||||
setTransform(...matrix) {
|
||||
canvas.transforms = [...(canvas.transforms ?? []), matrix];
|
||||
},
|
||||
drawImage(source, ...crop) {
|
||||
canvas.source = source;
|
||||
canvas.crop = crop;
|
||||
canvas.drawnWith = canvas.transforms?.at(-1) ?? null;
|
||||
},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
|
||||
putImageData(pixels) {
|
||||
@@ -1510,6 +1514,37 @@ test('regional transfer preserves deferrals, original Request, rejection and can
|
||||
assert.equal(await stage, false);
|
||||
});
|
||||
|
||||
test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => {
|
||||
const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap');
|
||||
const previous = globalThis.ImageBitmap;
|
||||
globalThis.ImageBitmap = class ImageBitmap {
|
||||
width = 256;
|
||||
height = 256;
|
||||
};
|
||||
try {
|
||||
const h = renderingHarness();
|
||||
void h.rendering.setFrame(
|
||||
{ ...snapshot, product: 'clouds-regional' },
|
||||
times[0],
|
||||
);
|
||||
const provider = h.providers[0];
|
||||
// Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again.
|
||||
provider.response = { promise: Promise.resolve(new ImageBitmap()) };
|
||||
const bitmapTile = await provider.requestImage(0, 0, 0, {});
|
||||
assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]);
|
||||
// An HTMLImageElement fallback is not pre-flipped.
|
||||
provider.response = {
|
||||
promise: Promise.resolve({ width: 256, height: 256 }),
|
||||
};
|
||||
const imageTile = await provider.requestImage(1, 0, 0, {});
|
||||
assert.equal(imageTile.drawnWith, null);
|
||||
h.rendering.clear();
|
||||
} finally {
|
||||
if (hadBitmap) globalThis.ImageBitmap = previous;
|
||||
else delete globalThis.ImageBitmap;
|
||||
}
|
||||
});
|
||||
|
||||
for (const kind of ['globe']) {
|
||||
test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => {
|
||||
let host,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCyberSonarSampler,
|
||||
isCyberContactSonarActive as isCyberSonarActive,
|
||||
CYBER_SONAR_RENDER_INTERVAL_MS,
|
||||
} from '../cyberSonar.js';
|
||||
import {
|
||||
getKeyholeFadeTuning,
|
||||
getKeyholeGeometry,
|
||||
@@ -170,6 +175,7 @@ let _resizeObserver = null;
|
||||
let _mutationObserver = null;
|
||||
let _observedOccluderElements = new WeakSet();
|
||||
let _occluderRefreshTimer = null;
|
||||
let _sonarRenderTimer = null;
|
||||
let _cockpitModeHandler = null;
|
||||
let _windowResizeHandler = null;
|
||||
let _cockpitActive = false;
|
||||
@@ -2443,7 +2449,7 @@ function paintCustomLane(lane) {
|
||||
}
|
||||
}
|
||||
|
||||
function paintEntryItem(item, keyhole) {
|
||||
function paintEntryItem(item, keyhole, sonar) {
|
||||
const { record, placement } = item;
|
||||
const entry = record.entry;
|
||||
let keyholeAlpha = 1;
|
||||
@@ -2468,7 +2474,8 @@ function paintEntryItem(item, keyhole) {
|
||||
item.temporalAlpha *
|
||||
record.distanceAlpha *
|
||||
record.altitudeAlpha *
|
||||
keyholeAlpha;
|
||||
keyholeAlpha *
|
||||
(sonar ? sonar.label(sonar.at(placement.anchorX, placement.anchorY)) : 1);
|
||||
if (finalAlpha <= 0.001) return;
|
||||
if (record.paintScale === 1) {
|
||||
paintOverlayEntry(
|
||||
@@ -2508,6 +2515,9 @@ function paintEntryItem(item, keyhole) {
|
||||
|
||||
function paintFrame(keyhole) {
|
||||
const started = nowMs();
|
||||
const sonar = isCyberSonarActive()
|
||||
? createCyberSonarSampler(_canvasWidth, _canvasHeight, started)
|
||||
: null;
|
||||
clearCanvas(true, false);
|
||||
_detectionSurfacePrepared = false;
|
||||
if (
|
||||
@@ -2528,7 +2538,7 @@ function paintFrame(keyhole) {
|
||||
// retains its former z5 position below every ordinary host entry at z6.
|
||||
paintCustomLane(lane);
|
||||
while (itemIndex < _paintCount && _paintQueue[itemIndex].lane === lane) {
|
||||
paintEntryItem(_paintQueue[itemIndex], keyhole);
|
||||
paintEntryItem(_paintQueue[itemIndex], keyhole, sonar);
|
||||
itemIndex++;
|
||||
}
|
||||
}
|
||||
@@ -2581,6 +2591,10 @@ function resetFrameDiagnostics() {
|
||||
}
|
||||
|
||||
function drawWorldOverlay() {
|
||||
if (_sonarRenderTimer !== null) {
|
||||
clearTimeout(_sonarRenderTimer);
|
||||
_sonarRenderTimer = null;
|
||||
}
|
||||
if (_destroyed || !_viewer || !_canvas || !_ctx) return;
|
||||
const timestamp = nowMs();
|
||||
if (!overlayHasPaintWork(timestamp)) {
|
||||
@@ -2622,6 +2636,15 @@ function drawWorldOverlay() {
|
||||
_diagnostics.projectionMs = nowMs() - projectionStarted;
|
||||
solveDomains(timestamp);
|
||||
paintFrame(keyhole);
|
||||
// Labels-only layers need a bounded sweep refresh even with detection off.
|
||||
// Empty/hidden hosts and teardown never retain scanner render demand.
|
||||
if (_paintRectCount > 0 && isCyberSonarActive()) {
|
||||
_sonarRenderTimer = setTimeout(() => {
|
||||
_sonarRenderTimer = null;
|
||||
if (!_destroyed && isCyberSonarActive())
|
||||
_viewer?.scene?.requestRender?.();
|
||||
}, CYBER_SONAR_RENDER_INTERVAL_MS);
|
||||
}
|
||||
const animationsRemaining =
|
||||
activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp);
|
||||
if (animationsRemaining > 0) _viewer.scene.requestRender?.();
|
||||
@@ -2679,6 +2702,8 @@ export function initWorldOverlay(viewer) {
|
||||
|
||||
/** Remove all listeners, observers, entries, diagnostics, and overlay DOM. */
|
||||
export function destroyWorldOverlay() {
|
||||
if (_sonarRenderTimer !== null) clearTimeout(_sonarRenderTimer);
|
||||
_sonarRenderTimer = null;
|
||||
// Tearing down a host that was never initialized must not arm the
|
||||
// post-destroy guard: sources are allowed to publish before the first
|
||||
// `initWorldOverlay`, and that buffering has to survive a stray destroy.
|
||||
|
||||
@@ -197,7 +197,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
|
||||
|
||||
assert.match(
|
||||
ui,
|
||||
/const collapsed = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
/const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
|
||||
'responsive auto-collapse must serialize the explicit expanded preference',
|
||||
);
|
||||
assert.match(
|
||||
@@ -218,9 +218,9 @@ test('parameterized Display presets keep one stable scroll owner', () => {
|
||||
const ui = readShellSource();
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
|
||||
assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
|
||||
assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
|
||||
assert.match(css, /#pp-toggles:not\(\.collapsed\) > \.pp-panel-body > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
|
||||
assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?displayPanelScroller\(this\._ppToggles\)\?\.scrollTop \|\| 0/);
|
||||
assert.match(rightRail, /scroller\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
|
||||
assert.match(
|
||||
ui,
|
||||
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
|
||||
|
||||
@@ -25,11 +25,11 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
||||
|
||||
test('Realtime schema exposes the authoritative 29-tool inventory', () => {
|
||||
test('Realtime schema exposes the authoritative 30-tool inventory', () => {
|
||||
const tools = realtimeTools();
|
||||
assert.equal(tools.length, 29);
|
||||
assert.equal(tools.length, 30);
|
||||
const names = tools.map((tool) => tool.name);
|
||||
assert.equal(new Set(names).size, 29, 'tool names are unique');
|
||||
assert.equal(new Set(names).size, 30, 'tool names are unique');
|
||||
assert.ok(names.includes('set_context_mode'));
|
||||
assert.ok(names.includes('control_cockpit'));
|
||||
assert.ok(names.includes('select_nearest_aircraft'));
|
||||
@@ -173,6 +173,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
// in the mic-test brief which tools moved — the session cache busts on any
|
||||
// schema change.
|
||||
const TOUCHED = new Set([
|
||||
'set_cyber_sonar',
|
||||
'set_context_mode',
|
||||
'control_cockpit',
|
||||
'set_panel_open',
|
||||
@@ -183,21 +184,29 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'analyst_query',
|
||||
'next_iss_pass',
|
||||
'next_satellite_pass',
|
||||
// Local ADS-B adds one layer enum value and its common-name mapping.
|
||||
'set_layer_visibility',
|
||||
]);
|
||||
const unchanged = realtimeTools()
|
||||
.filter((tool) => !TOUCHED.has(tool.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
assert.equal(unchanged.length, 19);
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((tool) => structuredClone(tool))
|
||||
.filter((tool) => tool.name !== 'set_cyber_sonar');
|
||||
// Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field.
|
||||
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
|
||||
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
|
||||
assert.equal(unchanged.length, 18);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
// Analyst additions and ISS wording correction are explicitly excluded above; all other tool definitions retain their pin.
|
||||
assert.equal(digest, '7411ea585e534795', 'an unchanged Realtime tool definition drifted');
|
||||
assert.equal(digest, '91935845ef2598b1', 'an unchanged Realtime tool definition drifted');
|
||||
});
|
||||
|
||||
test('Radio volume and mission speed share the Sharpen slider visual language', () => {
|
||||
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume']) {
|
||||
for (const id of ['cockpit-radio-volume', 'context-radio-mini-volume', 'radio-volume', 'sdr-volume']) {
|
||||
assert.match(
|
||||
html,
|
||||
new RegExp(`id="${id}"[^>]*class="gev-quantitative-slider"[^>]*type="range"`),
|
||||
@@ -272,7 +281,10 @@ test('Radio is nested inside Context with separate disclosure and power controls
|
||||
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
|
||||
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
|
||||
assert.match(radioBindings, /radioTunerPointerPosition\(/);
|
||||
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
|
||||
// Only the Cyber skin promotes Radio to a peer panel. Other themes retain
|
||||
// the embedded Context layout; theme round-trip behavior has separate tests.
|
||||
const baseCss = css.replace(readFileSync(new URL('./ui/styles/cyber.css', import.meta.url), 'utf8'), '');
|
||||
assert.doesNotMatch(baseCss, /#right-context-rail\s*>\s*#radio-panel/);
|
||||
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
|
||||
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
|
||||
assert.doesNotMatch(css, /\.context-radio-dock\.active:focus-within \.context-radio-mini/);
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
import {
|
||||
LOCAL_ADSB_POSITION_MARGIN_M,
|
||||
LOCAL_ADSB_REANCHOR_AFTER,
|
||||
LOCAL_ADSB_REFERENCE_MAX_AGE_MS,
|
||||
localAdsbFixIsPlausible,
|
||||
localAdsbSpeedLimitKt,
|
||||
} from '../sources/adsbRecords.js';
|
||||
|
||||
const MODE_S_POLYNOMIAL = 0xfff409;
|
||||
/** An aircraft with no CRC-valid message for this long is removed. */
|
||||
export const LOCAL_ADSB_STALE_MS = 60_000;
|
||||
const CPR_PAIR_MAX_AGE_MS = 10_000;
|
||||
/** Surface aircraft move slowly; an even/odd surface pair may be further apart. */
|
||||
const CPR_SURFACE_PAIR_MAX_AGE_MS = 25_000;
|
||||
/**
|
||||
* Half a surface CPR latitude zone (90° / 60 zones / 2 = 0.75°, 45 NM): a
|
||||
* single surface frame decodes unambiguously only against a reference known
|
||||
* to be closer than this to the aircraft.
|
||||
*/
|
||||
const SURFACE_REFERENCE_RANGE_M = 45 * 1_852;
|
||||
const KT_TO_MPS = 1_852 / 3_600;
|
||||
const CPR_SCALE = 131_072;
|
||||
const CPR_NZ = 15;
|
||||
const CALLSIGN_CHARSET =
|
||||
'#ABCDEFGHIJKLMNOPQRSTUVWXYZ#####_###############0123456789######';
|
||||
const PREAMBLE_SAMPLES = 16;
|
||||
const FRAME_BITS = 112;
|
||||
const FRAME_SAMPLES = PREAMBLE_SAMPLES + FRAME_BITS * 2;
|
||||
const STREAM_CARRY_BYTES = (FRAME_SAMPLES - 1) * 2;
|
||||
|
||||
function bit(bytes, index) {
|
||||
return (bytes[index >> 3] >> (7 - (index & 7))) & 1;
|
||||
}
|
||||
|
||||
function bits(bytes, start, length) {
|
||||
let value = 0;
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
value = value * 2 + bit(bytes, start + index);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveModulo(value, divisor) {
|
||||
return ((value % divisor) + divisor) % divisor;
|
||||
}
|
||||
|
||||
function cprNl(latitude) {
|
||||
const absolute = Math.abs(latitude);
|
||||
if (absolute >= 87) return absolute === 87 ? 2 : 1;
|
||||
const numerator = 1 - Math.cos(Math.PI / (2 * CPR_NZ));
|
||||
const denominator = Math.cos((Math.PI / 180) * absolute) ** 2;
|
||||
return Math.floor((2 * Math.PI) / Math.acos(1 - numerator / denominator));
|
||||
}
|
||||
|
||||
function normalizeLongitude(longitude) {
|
||||
let normalized = longitude;
|
||||
while (normalized > 180) normalized -= 360;
|
||||
while (normalized < -180) normalized += 360;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validReference(reference) {
|
||||
return (
|
||||
Boolean(reference) &&
|
||||
Number.isFinite(reference.latitude) &&
|
||||
Number.isFinite(reference.longitude)
|
||||
);
|
||||
}
|
||||
|
||||
/** The candidate `value + k * period` nearest `target`. */
|
||||
function nearestAlias(value, period, target) {
|
||||
return value + period * Math.round((target - value) / period);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locally decode one CPR frame against a reference within half a zone.
|
||||
* Surface frames use quarter-size zones (90° instead of 360°), so their
|
||||
* reference must be within 45 NM of the aircraft: only the aircraft's own
|
||||
* recent position qualifies (see `surfaceReference`).
|
||||
*/
|
||||
function decodeLocalCpr(frame, reference) {
|
||||
if (!validReference(reference)) return null;
|
||||
const span = frame.surface ? 90 : 360;
|
||||
const parity = frame.odd ? 1 : 0;
|
||||
const dLat = span / (4 * CPR_NZ - parity);
|
||||
const latitudeIndex =
|
||||
Math.floor(reference.latitude / dLat) +
|
||||
Math.floor(
|
||||
0.5 +
|
||||
positiveModulo(reference.latitude, dLat) / dLat -
|
||||
frame.latitude / CPR_SCALE,
|
||||
);
|
||||
let latitude = dLat * (latitudeIndex + frame.latitude / CPR_SCALE);
|
||||
if (latitude >= 270) latitude -= 360;
|
||||
if (latitude < -90 || latitude > 90) return null;
|
||||
|
||||
const longitudeZones = Math.max(cprNl(latitude) - parity, 1);
|
||||
const dLon = span / longitudeZones;
|
||||
const longitudeIndex =
|
||||
Math.floor(reference.longitude / dLon) +
|
||||
Math.floor(
|
||||
0.5 +
|
||||
positiveModulo(reference.longitude, dLon) / dLon -
|
||||
frame.longitude / CPR_SCALE,
|
||||
);
|
||||
const longitude = normalizeLongitude(
|
||||
dLon * (longitudeIndex + frame.longitude / CPR_SCALE),
|
||||
);
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
function decodeGlobalCpr(even, odd) {
|
||||
if (
|
||||
!even ||
|
||||
!odd ||
|
||||
Math.abs(even.receivedAt - odd.receivedAt) > CPR_PAIR_MAX_AGE_MS
|
||||
)
|
||||
return null;
|
||||
const latitudeIndex = Math.floor(
|
||||
(59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5,
|
||||
);
|
||||
let evenLatitude =
|
||||
6 * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE);
|
||||
let oddLatitude =
|
||||
(360 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE);
|
||||
if (evenLatitude >= 270) evenLatitude -= 360;
|
||||
if (oddLatitude >= 270) oddLatitude -= 360;
|
||||
if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null;
|
||||
|
||||
const useOdd = odd.receivedAt > even.receivedAt;
|
||||
const latitude = useOdd ? oddLatitude : evenLatitude;
|
||||
const longitudeZones = Math.max(cprNl(latitude) - (useOdd ? 1 : 0), 1);
|
||||
const longitudeIndex = Math.floor(
|
||||
(even.longitude * (cprNl(latitude) - 1) - odd.longitude * cprNl(latitude)) /
|
||||
CPR_SCALE +
|
||||
0.5,
|
||||
);
|
||||
const frame = useOdd ? odd : even;
|
||||
const longitude = normalizeLongitude(
|
||||
(360 / longitudeZones) *
|
||||
(positiveModulo(longitudeIndex, longitudeZones) +
|
||||
frame.longitude / CPR_SCALE),
|
||||
);
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* Globally decode an even/odd pair of SURFACE position frames. Surface CPR
|
||||
* encodes a quarter of the globe, so the pair leaves four candidate
|
||||
* latitudes/longitudes 90° apart; the one nearest `reference` (the aircraft's
|
||||
* last position or the receiver) is the answer, and without a reference
|
||||
* there is no answer.
|
||||
*/
|
||||
function decodeGlobalSurfaceCpr(even, odd, reference) {
|
||||
if (
|
||||
!even ||
|
||||
!odd ||
|
||||
!validReference(reference) ||
|
||||
Math.abs(even.receivedAt - odd.receivedAt) > CPR_SURFACE_PAIR_MAX_AGE_MS
|
||||
)
|
||||
return null;
|
||||
const latitudeIndex = Math.floor(
|
||||
(59 * even.latitude - 60 * odd.latitude) / CPR_SCALE + 0.5,
|
||||
);
|
||||
const evenLatitude = nearestAlias(
|
||||
(90 / 60) * (positiveModulo(latitudeIndex, 60) + even.latitude / CPR_SCALE),
|
||||
90,
|
||||
reference.latitude,
|
||||
);
|
||||
const oddLatitude = nearestAlias(
|
||||
(90 / 59) * (positiveModulo(latitudeIndex, 59) + odd.latitude / CPR_SCALE),
|
||||
90,
|
||||
reference.latitude,
|
||||
);
|
||||
if (Math.abs(evenLatitude) > 90 || Math.abs(oddLatitude) > 90) return null;
|
||||
if (cprNl(evenLatitude) !== cprNl(oddLatitude)) return null;
|
||||
|
||||
const useOdd = odd.receivedAt > even.receivedAt;
|
||||
const latitude = useOdd ? oddLatitude : evenLatitude;
|
||||
const nl = cprNl(latitude);
|
||||
const longitudeZones = Math.max(nl - (useOdd ? 1 : 0), 1);
|
||||
const longitudeIndex = Math.floor(
|
||||
(even.longitude * (nl - 1) - odd.longitude * nl) / CPR_SCALE + 0.5,
|
||||
);
|
||||
const frame = useOdd ? odd : even;
|
||||
const longitude = normalizeLongitude(
|
||||
nearestAlias(
|
||||
(90 / longitudeZones) *
|
||||
(positiveModulo(longitudeIndex, longitudeZones) +
|
||||
frame.longitude / CPR_SCALE),
|
||||
90,
|
||||
reference.longitude,
|
||||
),
|
||||
);
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ground speed in knots from a surface message's 7-bit movement field
|
||||
* (DO-260B §2.2.3.2.4.2): null for "no information" and reserved codes.
|
||||
*/
|
||||
function decodeSurfaceMovement(movement) {
|
||||
if (movement === 0 || movement > 124) return null;
|
||||
if (movement === 1) return 0;
|
||||
if (movement === 124) return 175;
|
||||
const bands = [
|
||||
[2, 0.125, 0.125],
|
||||
[9, 1, 0.25],
|
||||
[13, 2, 0.5],
|
||||
[39, 15, 1],
|
||||
[94, 70, 2],
|
||||
[109, 100, 5],
|
||||
];
|
||||
let band = bands[0];
|
||||
for (const candidate of bands) if (movement >= candidate[0]) band = candidate;
|
||||
const [firstCode, firstKt, stepKt] = band;
|
||||
return firstKt + (movement - firstCode) * stepKt;
|
||||
}
|
||||
|
||||
function decodeAltitude(bytes) {
|
||||
if (!bit(bytes, 47)) return null;
|
||||
const encoded = (bits(bytes, 40, 7) << 4) | bits(bytes, 48, 4);
|
||||
return encoded * 25 - 1_000;
|
||||
}
|
||||
|
||||
function decodeCallsign(bytes) {
|
||||
let callsign = '';
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
callsign += CALLSIGN_CHARSET[bits(bytes, 40 + index * 6, 6)] || ' ';
|
||||
}
|
||||
return callsign.replace(/[_#]/g, ' ').trim();
|
||||
}
|
||||
|
||||
function decodeVelocity(bytes) {
|
||||
const subtype = bits(bytes, 37, 3);
|
||||
if (subtype !== 1 && subtype !== 2) return null;
|
||||
const multiplier = subtype === 2 ? 4 : 1;
|
||||
const eastWestRaw = bits(bytes, 46, 10);
|
||||
const northSouthRaw = bits(bytes, 57, 10);
|
||||
if (!eastWestRaw || !northSouthRaw) return null;
|
||||
const eastWest = (eastWestRaw - 1) * multiplier * (bit(bytes, 45) ? -1 : 1);
|
||||
const northSouth =
|
||||
(northSouthRaw - 1) * multiplier * (bit(bytes, 56) ? -1 : 1);
|
||||
const speedKt = Math.hypot(eastWest, northSouth);
|
||||
const headingDeg = positiveModulo(
|
||||
(Math.atan2(eastWest, northSouth) * 180) / Math.PI,
|
||||
360,
|
||||
);
|
||||
const verticalRaw = bits(bytes, 69, 9);
|
||||
const verticalRateFpm = verticalRaw
|
||||
? (verticalRaw - 1) * 64 * (bit(bytes, 68) ? -1 : 1)
|
||||
: null;
|
||||
return { speedKt, headingDeg, verticalRateFpm };
|
||||
}
|
||||
|
||||
/** Compute the 24-bit Mode S parity remainder. Valid extended squitters return zero. */
|
||||
export function modeSChecksum(bytes) {
|
||||
let remainder = 0;
|
||||
for (let index = 0; index < bytes.length * 8; index += 1) {
|
||||
const feedback = bit(bytes, index) ^ ((remainder >> 23) & 1);
|
||||
remainder = (remainder << 1) & 0xffffff;
|
||||
if (feedback) remainder ^= MODE_S_POLYNOMIAL;
|
||||
}
|
||||
return remainder;
|
||||
}
|
||||
|
||||
/** Decode one 112-bit DF17/DF18 extended-squitter message. */
|
||||
export function decodeAdsbMessage(bytes, { receivedAt = Date.now() } = {}) {
|
||||
if (!(bytes instanceof Uint8Array) || bytes.length !== 14) return null;
|
||||
const downlinkFormat = bits(bytes, 0, 5);
|
||||
if (
|
||||
(downlinkFormat !== 17 && downlinkFormat !== 18) ||
|
||||
modeSChecksum(bytes) !== 0
|
||||
)
|
||||
return null;
|
||||
const icao = [...bytes.slice(1, 4)]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
const typeCode = bits(bytes, 32, 5);
|
||||
const message = { icao, typeCode, receivedAt };
|
||||
if (typeCode >= 1 && typeCode <= 4) {
|
||||
message.callsign = decodeCallsign(bytes);
|
||||
// dump1090 convention: category set A = TC 4, B = TC 3, C = TC 2, D = TC 1,
|
||||
// followed by the 3-bit emitter category ('A7' rotorcraft, 'A1' light).
|
||||
message.category = `${String.fromCharCode(69 - typeCode)}${bits(bytes, 37, 3)}`;
|
||||
} else if (typeCode >= 5 && typeCode <= 8) {
|
||||
// Surface position: movement, ground track when its status bit is set,
|
||||
// and a quarter-globe CPR position; there is no altitude on the ground.
|
||||
message.onGround = true;
|
||||
message.altitudeFt = null;
|
||||
const speedKt = decodeSurfaceMovement(bits(bytes, 37, 7));
|
||||
if (speedKt !== null) message.speedKt = speedKt;
|
||||
if (bit(bytes, 44)) message.headingDeg = (bits(bytes, 45, 7) * 360) / 128;
|
||||
message.cpr = {
|
||||
surface: true,
|
||||
odd: Boolean(bit(bytes, 53)),
|
||||
latitude: bits(bytes, 54, 17),
|
||||
longitude: bits(bytes, 71, 17),
|
||||
receivedAt,
|
||||
};
|
||||
} else if (typeCode >= 9 && typeCode <= 18) {
|
||||
message.onGround = false;
|
||||
message.altitudeFt = decodeAltitude(bytes);
|
||||
message.cpr = {
|
||||
odd: Boolean(bit(bytes, 53)),
|
||||
latitude: bits(bytes, 54, 17),
|
||||
longitude: bits(bytes, 71, 17),
|
||||
receivedAt,
|
||||
};
|
||||
} else if (typeCode === 19) {
|
||||
Object.assign(message, decodeVelocity(bytes) || {});
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function recentReference(track, receivedAt) {
|
||||
if (
|
||||
!Number.isFinite(track.latitude) ||
|
||||
!Number.isFinite(track.longitude) ||
|
||||
!Number.isFinite(track.lastPositionAt) ||
|
||||
receivedAt - track.lastPositionAt > LOCAL_ADSB_REFERENCE_MAX_AGE_MS
|
||||
)
|
||||
return null;
|
||||
return { latitude: track.latitude, longitude: track.longitude };
|
||||
}
|
||||
|
||||
/**
|
||||
* The aircraft's own last position as a single-frame SURFACE reference, when
|
||||
* it is established to be within 45 NM: recent, and the aircraft cannot have
|
||||
* covered 45 NM since at its speed limit (the speed check's limit). The
|
||||
* receiver location never qualifies, since how far away a heard aircraft is
|
||||
* cannot be known from one frame.
|
||||
*/
|
||||
function surfaceReference(track, message) {
|
||||
const reference = recentReference(track, message.receivedAt);
|
||||
if (!reference) return null;
|
||||
const groundSpeedKt = Number.isFinite(track.speedKt)
|
||||
? Math.max(
|
||||
track.speedKt,
|
||||
Number.isFinite(message.speedKt) ? message.speedKt : 0,
|
||||
)
|
||||
: null;
|
||||
const limitKt = localAdsbSpeedLimitKt({
|
||||
groundSpeedKt,
|
||||
category: track.category,
|
||||
});
|
||||
const reachM =
|
||||
LOCAL_ADSB_POSITION_MARGIN_M +
|
||||
((message.receivedAt - track.lastPositionAt + 1_000) / 1_000) *
|
||||
limitKt *
|
||||
KT_TO_MPS;
|
||||
return reachM < SURFACE_REFERENCE_RANGE_M ? reference : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge one decoded message into a stable aircraft record.
|
||||
*
|
||||
* Airborne (TC 9–18) and surface (TC 5–8) positions are paired separately.
|
||||
* A surface pair needs a reference to pick its quadrant (the aircraft's last
|
||||
* position, else the receiver); surface messages also set `onGround` and
|
||||
* clear the altitude, and the next airborne position clears `onGround`.
|
||||
*
|
||||
* A position comes from a fresh even/odd pair (global CPR), else from one
|
||||
* frame decoded relative to the aircraft's own last accepted position when it
|
||||
* is under 10 minutes old, else (airborne only) relative to the receiver
|
||||
* location. A single surface frame needs the aircraft's own position within
|
||||
* 45 NM, so a surface track is always seeded by a pair. Every
|
||||
* candidate then passes the dump1090-style speed check against the last
|
||||
* accepted fix; a failing fix is counted (`rejectedPositions` on the track and
|
||||
* `positionsRejected` on the optional `stats`) and not applied. From the
|
||||
* third consecutive rejection on, a globally decoded fix is accepted as the
|
||||
* new reference, so one bad accepted fix cannot freeze a track.
|
||||
* @param {Map<string, object>} tracks Tracks keyed by uppercase ICAO.
|
||||
* @param {object} message Output of `decodeAdsbMessage`.
|
||||
* @param {{latitude:number, longitude:number}|null} [receiverLocation]
|
||||
* @param {{positionsRejected?:number}} [stats] Mutable decoder counters.
|
||||
* @returns {object|null} The updated track.
|
||||
*/
|
||||
export function updateAircraftTrack(
|
||||
tracks,
|
||||
message,
|
||||
receiverLocation = null,
|
||||
stats = null,
|
||||
) {
|
||||
if (!(tracks instanceof Map) || !message?.icao) return null;
|
||||
const prior = tracks.get(message.icao) || {
|
||||
icao: message.icao,
|
||||
callsign: null,
|
||||
category: null,
|
||||
altitudeFt: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
speedKt: null,
|
||||
headingDeg: null,
|
||||
verticalRateFpm: null,
|
||||
messages: 0,
|
||||
lastSeen: 0,
|
||||
lastPositionAt: null,
|
||||
onGround: false,
|
||||
cprEven: null,
|
||||
cprOdd: null,
|
||||
cprSurfaceEven: null,
|
||||
cprSurfaceOdd: null,
|
||||
rejectedPositions: 0,
|
||||
rejectStreak: 0,
|
||||
};
|
||||
const next = {
|
||||
...prior,
|
||||
messages: prior.messages + 1,
|
||||
lastSeen: message.receivedAt,
|
||||
};
|
||||
for (const key of [
|
||||
'callsign',
|
||||
'category',
|
||||
'altitudeFt',
|
||||
'speedKt',
|
||||
'headingDeg',
|
||||
'verticalRateFpm',
|
||||
]) {
|
||||
if (message[key] !== undefined && message[key] !== null)
|
||||
next[key] = message[key];
|
||||
}
|
||||
// A position message says where the aircraft is: on the surface (no
|
||||
// altitude, no climb) or airborne again.
|
||||
if (typeof message.onGround === 'boolean') {
|
||||
next.onGround = message.onGround;
|
||||
if (message.onGround) {
|
||||
next.altitudeFt = null;
|
||||
next.verticalRateFpm = null;
|
||||
}
|
||||
}
|
||||
if (message.cpr) {
|
||||
const surface = Boolean(message.cpr.surface);
|
||||
// Airborne and surface frames use different zone sizes: never pair them.
|
||||
if (surface) {
|
||||
if (message.cpr.odd) next.cprSurfaceOdd = message.cpr;
|
||||
else next.cprSurfaceEven = message.cpr;
|
||||
} else if (message.cpr.odd) next.cprOdd = message.cpr;
|
||||
else next.cprEven = message.cpr;
|
||||
const reference = recentReference(prior, message.receivedAt);
|
||||
const global = surface
|
||||
? decodeGlobalSurfaceCpr(
|
||||
next.cprSurfaceEven,
|
||||
next.cprSurfaceOdd,
|
||||
reference || receiverLocation,
|
||||
)
|
||||
: decodeGlobalCpr(next.cprEven, next.cprOdd);
|
||||
const position =
|
||||
global ||
|
||||
(surface
|
||||
? decodeLocalCpr(message.cpr, surfaceReference(prior, message))
|
||||
: decodeLocalCpr(message.cpr, reference) ||
|
||||
decodeLocalCpr(message.cpr, receiverLocation));
|
||||
if (position) {
|
||||
const plausible =
|
||||
!reference ||
|
||||
localAdsbFixIsPlausible(
|
||||
{
|
||||
lat: prior.latitude,
|
||||
lon: prior.longitude,
|
||||
at: prior.lastPositionAt,
|
||||
},
|
||||
{
|
||||
lat: position.latitude,
|
||||
lon: position.longitude,
|
||||
at: message.receivedAt,
|
||||
},
|
||||
{ groundSpeedKt: next.speedKt, category: next.category },
|
||||
);
|
||||
const reanchor =
|
||||
!plausible &&
|
||||
Boolean(global) &&
|
||||
(prior.rejectStreak || 0) + 1 >= LOCAL_ADSB_REANCHOR_AFTER;
|
||||
if (plausible || reanchor) {
|
||||
Object.assign(next, position);
|
||||
next.lastPositionAt = message.receivedAt;
|
||||
next.rejectStreak = 0;
|
||||
} else {
|
||||
next.rejectedPositions = (prior.rejectedPositions || 0) + 1;
|
||||
next.rejectStreak = (prior.rejectStreak || 0) + 1;
|
||||
if (stats) stats.positionsRejected = (stats.positionsRejected || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracks.set(message.icao, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Remove aircraft that have not transmitted recently. */
|
||||
export function pruneAircraftTracks(
|
||||
tracks,
|
||||
now = Date.now(),
|
||||
ttlMs = LOCAL_ADSB_STALE_MS,
|
||||
) {
|
||||
if (!(tracks instanceof Map)) return 0;
|
||||
let removed = 0;
|
||||
for (const [icao, aircraft] of tracks) {
|
||||
if (now - aircraft.lastSeen < ttlMs) continue;
|
||||
tracks.delete(icao);
|
||||
removed += 1;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function preambleContrast(power, offset) {
|
||||
const pulseOffsets = [0, 2, 7, 9];
|
||||
// Only the six defined gaps between Mode S preamble pulses establish the
|
||||
// local floor. Samples 10–15 can contain the trailing shape of the last RF
|
||||
// pulse on a real 2 Msps receiver and treating them as quiet rejects frames
|
||||
// that a clean synthetic fixture does not expose.
|
||||
const quietOffsets = [1, 3, 4, 5, 6, 8];
|
||||
let pulseMean = 0;
|
||||
let quietMean = 0;
|
||||
for (const index of pulseOffsets) pulseMean += power[offset + index];
|
||||
for (const index of quietOffsets) quietMean += power[offset + index];
|
||||
pulseMean /= pulseOffsets.length;
|
||||
quietMean /= quietOffsets.length;
|
||||
const contrast = pulseMean - quietMean;
|
||||
if (contrast < 120 || pulseMean < quietMean * 1.8) return 0;
|
||||
const decisionLevel = quietMean + contrast * 0.35;
|
||||
if (pulseOffsets.some((index) => power[offset + index] <= decisionLevel))
|
||||
return 0;
|
||||
return contrast;
|
||||
}
|
||||
|
||||
/** Extract CRC-valid 112-bit ADS-B messages from unsigned 8-bit interleaved IQ. */
|
||||
export function extractAdsbMessages(buffer, sampleRate = 2_000_000) {
|
||||
if (
|
||||
!(buffer instanceof ArrayBuffer) ||
|
||||
Math.abs(sampleRate - 2_000_000) > 30_000
|
||||
)
|
||||
return [];
|
||||
const iq = new Uint8Array(buffer);
|
||||
const sampleCount = Math.floor(iq.length / 2);
|
||||
const power = new Float32Array(sampleCount);
|
||||
for (let index = 0; index < sampleCount; index += 1) {
|
||||
const i = iq[index * 2] - 127.5;
|
||||
const q = iq[index * 2 + 1] - 127.5;
|
||||
power[index] = i * i + q * q;
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
for (let offset = 0; offset <= sampleCount - FRAME_SAMPLES; offset += 1) {
|
||||
const contrast = preambleContrast(power, offset);
|
||||
if (!contrast) continue;
|
||||
const bytes = new Uint8Array(14);
|
||||
let uncertainBits = 0;
|
||||
for (let bitIndex = 0; bitIndex < FRAME_BITS; bitIndex += 1) {
|
||||
const sampleOffset = offset + PREAMBLE_SAMPLES + bitIndex * 2;
|
||||
const first = power[sampleOffset];
|
||||
const second = power[sampleOffset + 1];
|
||||
const total = first + second;
|
||||
const difference = Math.abs(first - second);
|
||||
if (
|
||||
difference < Math.max(20, contrast * 0.02) ||
|
||||
difference / Math.max(total, 1) < 0.04
|
||||
)
|
||||
uncertainBits += 1;
|
||||
if (first > second) bytes[bitIndex >> 3] |= 1 << (7 - (bitIndex & 7));
|
||||
}
|
||||
if (uncertainBits <= 10 && modeSChecksum(bytes) === 0) {
|
||||
messages.push(bytes);
|
||||
offset += FRAME_SAMPLES - 1;
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** Preserve enough IQ between USB reads to recover frames split at a block edge. */
|
||||
export class AdsbStreamDecoder {
|
||||
constructor() {
|
||||
this._carry = new Uint8Array(0);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this._carry = new Uint8Array(0);
|
||||
}
|
||||
|
||||
extract(buffer, sampleRate = 2_000_000) {
|
||||
if (!(buffer instanceof ArrayBuffer)) return [];
|
||||
const incoming = new Uint8Array(buffer);
|
||||
let combined = incoming;
|
||||
if (this._carry.length) {
|
||||
combined = new Uint8Array(this._carry.length + incoming.length);
|
||||
combined.set(this._carry);
|
||||
combined.set(incoming, this._carry.length);
|
||||
}
|
||||
this._carry = combined.slice(
|
||||
Math.max(0, combined.length - STREAM_CARRY_BYTES),
|
||||
);
|
||||
return extractAdsbMessages(combined.buffer, sampleRate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { classifyAircraft } from '../data/aircraftClass.js';
|
||||
import { recordFromDecoderTrack } from '../sources/adsbRecords.js';
|
||||
import {
|
||||
AdsbStreamDecoder,
|
||||
LOCAL_ADSB_STALE_MS,
|
||||
decodeAdsbMessage,
|
||||
extractAdsbMessages,
|
||||
modeSChecksum,
|
||||
pruneAircraftTracks,
|
||||
updateAircraftTrack,
|
||||
} from './adsbDecoder.js';
|
||||
|
||||
function fromHex(value) {
|
||||
return Uint8Array.from(value.match(/../g), (pair) =>
|
||||
Number.parseInt(pair, 16),
|
||||
);
|
||||
}
|
||||
|
||||
function synthesizeIq(
|
||||
bytes,
|
||||
{ floor = 128, high = 255, preambleTail = null } = {},
|
||||
) {
|
||||
const sampleCount = 16 + 112 * 2 + 8;
|
||||
const iq = new Uint8Array(sampleCount * 2).fill(128);
|
||||
for (let sample = 0; sample < sampleCount; sample += 1)
|
||||
iq[sample * 2] = floor;
|
||||
const pulse = (sample) => {
|
||||
iq[sample * 2] = high;
|
||||
};
|
||||
for (const offset of [0, 2, 7, 9]) pulse(offset);
|
||||
if (Number.isFinite(preambleTail)) {
|
||||
for (let sample = 10; sample < 16; sample += 1)
|
||||
iq[sample * 2] = preambleTail;
|
||||
}
|
||||
for (let index = 0; index < 112; index += 1) {
|
||||
const value = (bytes[index >> 3] >> (7 - (index & 7))) & 1;
|
||||
pulse(16 + index * 2 + (value ? 0 : 1));
|
||||
}
|
||||
return iq.buffer;
|
||||
}
|
||||
|
||||
test('decodes standard callsign, velocity, and altitude examples', () => {
|
||||
const callsign = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098'));
|
||||
assert.equal(callsign.callsign, 'KLM1023');
|
||||
assert.equal(callsign.icao, '4840D6');
|
||||
|
||||
const velocity = decodeAdsbMessage(fromHex('8D485020994409940838175B284F'));
|
||||
assert.ok(Math.abs(velocity.speedKt - 159.2) < 0.1);
|
||||
assert.ok(Math.abs(velocity.headingDeg - 182.88) < 0.1);
|
||||
assert.equal(velocity.verticalRateFpm, -832);
|
||||
|
||||
const position = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'));
|
||||
assert.equal(position.altitudeFt, 38_000);
|
||||
assert.equal(position.cpr.odd, false);
|
||||
});
|
||||
|
||||
test('globally decodes a valid even/odd CPR pair and expires tracks at 60 seconds', () => {
|
||||
const tracks = new Map();
|
||||
const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), {
|
||||
receivedAt: 1_000,
|
||||
});
|
||||
const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
|
||||
receivedAt: 1_500,
|
||||
});
|
||||
updateAircraftTrack(tracks, even);
|
||||
const aircraft = updateAircraftTrack(tracks, odd);
|
||||
assert.ok(Math.abs(aircraft.latitude - 52.26578) < 0.0001);
|
||||
assert.ok(Math.abs(aircraft.longitude - 3.93891) < 0.0001);
|
||||
assert.equal(aircraft.lastPositionAt, 1_500);
|
||||
assert.equal(LOCAL_ADSB_STALE_MS, 60_000);
|
||||
assert.equal(
|
||||
pruneAircraftTracks(tracks, 61_499),
|
||||
0,
|
||||
'contact survives below 60 seconds',
|
||||
);
|
||||
assert.equal(
|
||||
pruneAircraftTracks(tracks, 61_500),
|
||||
1,
|
||||
'contact expires at 60 seconds',
|
||||
);
|
||||
assert.equal(tracks.size, 0);
|
||||
});
|
||||
|
||||
test('extracts a CRC-valid Mode S frame from synthetic 2 Msps IQ', () => {
|
||||
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
|
||||
assert.equal(modeSChecksum(bytes), 0);
|
||||
const frames = extractAdsbMessages(synthesizeIq(bytes), 2_000_000);
|
||||
assert.equal(frames.length, 1);
|
||||
assert.deepEqual(frames[0], bytes);
|
||||
});
|
||||
|
||||
test('accepts a valid weak frame above an elevated local noise floor', () => {
|
||||
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
|
||||
const frames = extractAdsbMessages(
|
||||
synthesizeIq(bytes, { floor: 140, high: 150 }),
|
||||
2_000_000,
|
||||
);
|
||||
assert.equal(frames.length, 1);
|
||||
assert.deepEqual(frames[0], bytes);
|
||||
});
|
||||
|
||||
test('accepts a real-shaped preamble with energy trailing its final pulse', () => {
|
||||
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
|
||||
const frames = extractAdsbMessages(
|
||||
synthesizeIq(bytes, {
|
||||
floor: 138,
|
||||
high: 148,
|
||||
preambleTail: 145,
|
||||
}),
|
||||
2_000_000,
|
||||
);
|
||||
assert.equal(frames.length, 1);
|
||||
assert.deepEqual(frames[0], bytes);
|
||||
});
|
||||
|
||||
test('preserves a Mode S frame split across consecutive USB blocks', () => {
|
||||
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
|
||||
const iq = new Uint8Array(synthesizeIq(bytes));
|
||||
const decoder = new AdsbStreamDecoder();
|
||||
assert.deepEqual(decoder.extract(iq.slice(0, 300).buffer, 2_000_000), []);
|
||||
assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), [bytes]);
|
||||
decoder.reset();
|
||||
assert.deepEqual(decoder.extract(iq.slice(300).buffer, 2_000_000), []);
|
||||
});
|
||||
|
||||
test('rejects corrupt frames and unsupported sample rates', () => {
|
||||
const bytes = fromHex('8D4840D6202CC371C32CE0576098');
|
||||
bytes[5] ^= 0x01;
|
||||
assert.notEqual(modeSChecksum(bytes), 0);
|
||||
assert.equal(decodeAdsbMessage(bytes), null);
|
||||
assert.deepEqual(extractAdsbMessages(synthesizeIq(bytes), 1_024_000), []);
|
||||
});
|
||||
|
||||
const AUSTIN_RECEIVER = Object.freeze({ latitude: 30.27, longitude: -97.8 });
|
||||
const fixtureFrames = readFileSync(
|
||||
new URL('../data/fixtures/adsb-austin-frames.txt', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => fromHex(line.replace(/^\*|;$/g, '')));
|
||||
const dump1090 = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../data/fixtures/adsb-austin-dump1090-aircraft.json',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
// The capture keeps receive order but not receive times, so frames are spaced
|
||||
// 100 ms apart: every even/odd pair then falls inside the 10 s CPR window.
|
||||
function decodeFixture(receiverLocation) {
|
||||
const tracks = new Map();
|
||||
fixtureFrames.forEach((bytes, index) => {
|
||||
const message = decodeAdsbMessage(bytes, { receivedAt: (index + 1) * 100 });
|
||||
if (message) updateAircraftTrack(tracks, message, receiverLocation);
|
||||
});
|
||||
return tracks;
|
||||
}
|
||||
|
||||
test('real Austin frames decode to the same aircraft, callsigns and positions as dump1090', () => {
|
||||
assert.equal(fixtureFrames.length, 112);
|
||||
const tracks = decodeFixture(AUSTIN_RECEIVER);
|
||||
const heard = [...tracks.keys()].map((icao) => icao.toLowerCase()).sort();
|
||||
assert.deepEqual(heard, dump1090.aircraft.map((entry) => entry.hex).sort());
|
||||
for (const oracle of dump1090.aircraft) {
|
||||
const track = tracks.get(oracle.hex.toUpperCase());
|
||||
if (oracle.flight)
|
||||
assert.equal(track.callsign, oracle.flight.trim(), oracle.hex);
|
||||
if (Number.isFinite(oracle.alt_baro))
|
||||
assert.equal(track.altitudeFt, oracle.alt_baro, oracle.hex);
|
||||
if (Number.isFinite(oracle.gs))
|
||||
assert.ok(Math.abs(track.speedKt - oracle.gs) < 0.5, oracle.hex);
|
||||
if (Number.isFinite(oracle.track))
|
||||
assert.ok(Math.abs(track.headingDeg - oracle.track) < 0.5, oracle.hex);
|
||||
if (Number.isFinite(oracle.baro_rate))
|
||||
assert.equal(track.verticalRateFpm, oracle.baro_rate, oracle.hex);
|
||||
if (!Number.isFinite(oracle.lat)) continue;
|
||||
assert.ok(
|
||||
Math.abs(track.latitude - oracle.lat) < 0.01,
|
||||
`${oracle.hex} latitude`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(track.longitude - oracle.lon) < 0.01,
|
||||
`${oracle.hex} longitude`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('receiver-relative CPR positions an even-only aircraft that dump1090 left unpositioned', () => {
|
||||
// SKW3301 (a15c54) sent three airborne positions, all even-parity, so no
|
||||
// global even/odd pair exists. dump1090 ran without a receiver location and
|
||||
// could not position it; with one, the local decode places it near AUS.
|
||||
const skywest = fixtureFrames.filter(
|
||||
(bytes) =>
|
||||
decodeAdsbMessage(bytes)?.icao === 'A15C54' &&
|
||||
decodeAdsbMessage(bytes).cpr,
|
||||
);
|
||||
assert.equal(skywest.length, 3);
|
||||
assert.ok(
|
||||
skywest.every((bytes) => decodeAdsbMessage(bytes).cpr.odd === false),
|
||||
);
|
||||
|
||||
const located = decodeFixture(AUSTIN_RECEIVER).get('A15C54');
|
||||
assert.ok(Math.abs(located.latitude - 30.1599) < 0.01);
|
||||
assert.ok(Math.abs(located.longitude + 97.7903) < 0.01);
|
||||
|
||||
const unlocated = decodeFixture(null);
|
||||
assert.equal(unlocated.get('A15C54').latitude, null);
|
||||
const positioned = [...unlocated.values()]
|
||||
.filter((track) => Number.isFinite(track.latitude))
|
||||
.map((track) => track.icao.toLowerCase())
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
positioned,
|
||||
dump1090.aircraft
|
||||
.filter((entry) => Number.isFinite(entry.lat))
|
||||
.map((entry) => entry.hex)
|
||||
.sort(),
|
||||
'without a receiver location the positioned set matches dump1090 exactly',
|
||||
);
|
||||
});
|
||||
|
||||
// A second real capture over Austin with receive times: 846 extended
|
||||
// squitters from six aircraft, each frame timed by interpolating between the
|
||||
// frames whose decoded position matches a dump1090 fix (dump1090's `t -
|
||||
// seen_pos`), plus dump1090's own positions for the same interval.
|
||||
const timed = JSON.parse(
|
||||
readFileSync(
|
||||
new URL('../data/fixtures/adsb-austin-capture-timed.json', import.meta.url),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
|
||||
function nauticalMiles(a, b) {
|
||||
const toRad = Math.PI / 180;
|
||||
const dLat = (b.lat - a.lat) * toRad;
|
||||
const dLon = (b.lon - a.lon) * toRad;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * 3440.065 * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
function replayTimed({ inject = null, receiver = timed.receiver } = {}) {
|
||||
const tracks = new Map();
|
||||
const stats = {};
|
||||
const accepted = new Map();
|
||||
for (const [receivedAt, hex] of timed.frames) {
|
||||
const message = decodeAdsbMessage(fromHex(hex), { receivedAt });
|
||||
if (!message) continue;
|
||||
for (const extra of inject?.(message) || []) {
|
||||
updateAircraftTrack(tracks, extra, receiver, stats);
|
||||
}
|
||||
const track = updateAircraftTrack(tracks, message, receiver, stats);
|
||||
if (message.cpr && track.lastPositionAt === receivedAt) {
|
||||
const key = message.icao.toLowerCase();
|
||||
if (!accepted.has(key)) accepted.set(key, []);
|
||||
accepted.get(key).push({
|
||||
at: receivedAt,
|
||||
lat: track.latitude,
|
||||
lon: track.longitude,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { tracks, stats, accepted };
|
||||
}
|
||||
|
||||
function dump1090Final(hex) {
|
||||
const [, lat, lon] = timed.dump1090[hex].fixes.at(-1);
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
test('identification messages decode to the dump1090 emitter category strings', () => {
|
||||
const { tracks } = replayTimed();
|
||||
const decoded = Object.fromEntries(
|
||||
Object.keys(timed.dump1090).map((hex) => [
|
||||
hex,
|
||||
tracks.get(hex.toUpperCase()).category,
|
||||
]),
|
||||
);
|
||||
assert.deepEqual(decoded, {
|
||||
'0d0c07': 'A2',
|
||||
a3627d: 'A3',
|
||||
a0b702: 'A7',
|
||||
a5b3a7: 'A3',
|
||||
abe7c5: 'A3',
|
||||
a27e81: 'A1',
|
||||
});
|
||||
for (const [hex, oracle] of Object.entries(timed.dump1090))
|
||||
assert.equal(decoded[hex], oracle.category, hex);
|
||||
// Category set D (type code 1) and C (type code 2) follow the same rule.
|
||||
const message = decodeAdsbMessage(fromHex('8D4840D6202CC371C32CE0576098'));
|
||||
assert.equal(message.typeCode, 4);
|
||||
assert.equal(message.category, 'A0');
|
||||
});
|
||||
|
||||
test('145TX, a rotorcraft (A7), classifies as a helicopter', () => {
|
||||
const { tracks } = replayTimed();
|
||||
const heli = tracks.get('A0B702');
|
||||
assert.equal(heli.callsign, '145TX');
|
||||
assert.equal(classifyAircraft({ category: heli.category }), 'helicopter');
|
||||
assert.equal(
|
||||
classifyAircraft({ category: tracks.get('A27E81').category }),
|
||||
'light',
|
||||
);
|
||||
});
|
||||
|
||||
test('the speed check refuses none of the capture and every track ends on dump1090', () => {
|
||||
const { tracks, stats, accepted } = replayTimed();
|
||||
assert.equal(stats.positionsRejected || 0, 0);
|
||||
for (const hex of Object.keys(timed.dump1090)) {
|
||||
const track = tracks.get(hex.toUpperCase());
|
||||
assert.equal(track.rejectedPositions, 0, `${hex} rejected a fix`);
|
||||
const fixes = accepted.get(hex);
|
||||
assert.ok(fixes.length >= 20, `${hex}: ${fixes.length} fixes`);
|
||||
const delta = nauticalMiles(fixes.at(-1), dump1090Final(hex));
|
||||
assert.ok(delta < 0.3, `${hex} ends ${delta.toFixed(3)} nm from dump1090`);
|
||||
}
|
||||
});
|
||||
|
||||
test("N26VB's 1.0 nm step spans a 27 s reception gap at its reported speed", () => {
|
||||
// The field report read this as a bad decode. The step follows single odd
|
||||
// frames decoded against the receiver location, which dump1090 (run without
|
||||
// one) never positions; the implied speed matches the reported 134 kt.
|
||||
const { tracks, accepted } = replayTimed();
|
||||
const fixes = accepted.get('a27e81');
|
||||
let widest = null;
|
||||
for (let index = 1; index < fixes.length; index += 1) {
|
||||
const step = nauticalMiles(fixes[index - 1], fixes[index]);
|
||||
if (!widest || step > widest.step)
|
||||
widest = {
|
||||
step,
|
||||
seconds: (fixes[index].at - fixes[index - 1].at) / 1000,
|
||||
};
|
||||
}
|
||||
assert.ok(Math.abs(widest.step - 1.0) < 0.05, `${widest.step} nm`);
|
||||
assert.ok(widest.seconds > 25, `${widest.seconds} s`);
|
||||
const impliedKt = widest.step / (widest.seconds / 3600);
|
||||
assert.ok(Math.abs(impliedKt - 134) < 10, `${impliedKt.toFixed(0)} kt`);
|
||||
assert.equal(tracks.get('A27E81').rejectedPositions, 0);
|
||||
});
|
||||
|
||||
test('a corrupt even/odd pair is refused and the track stays on dump1090', () => {
|
||||
// Pair a foreign odd frame (ENY3344, 27 nm away) with N26VB's fresh even
|
||||
// frame: the global decode lands far off the track, as a mixed-up pair does.
|
||||
const foreign = timed.frames
|
||||
.map(([, hex]) => decodeAdsbMessage(fromHex(hex)))
|
||||
.find((message) => message?.icao === 'A3627D' && message.cpr?.odd);
|
||||
let injected = 0;
|
||||
const { tracks, stats, accepted } = replayTimed({
|
||||
inject(message) {
|
||||
if (message.icao !== 'A27E81' || message.cpr?.odd !== false) return [];
|
||||
if (injected >= 2) return [];
|
||||
injected += 1;
|
||||
return [
|
||||
{
|
||||
...foreign,
|
||||
icao: 'A27E81',
|
||||
receivedAt: message.receivedAt - 1,
|
||||
cpr: { ...foreign.cpr, receivedAt: message.receivedAt - 1 },
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
const track = tracks.get('A27E81');
|
||||
assert.ok(track.rejectedPositions >= 1);
|
||||
assert.equal(stats.positionsRejected, track.rejectedPositions);
|
||||
for (const fix of accepted.get('a27e81'))
|
||||
assert.ok(
|
||||
nauticalMiles(fix, { lat: 30.32, lon: -97.91 }) < 3,
|
||||
'no accepted fix leaves the track',
|
||||
);
|
||||
assert.ok(
|
||||
nauticalMiles(accepted.get('a27e81').at(-1), dump1090Final('a27e81')) < 0.3,
|
||||
);
|
||||
});
|
||||
|
||||
test('a lone frame decodes relative to the aircraft own recent position', () => {
|
||||
const tracks = new Map();
|
||||
const even = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), {
|
||||
receivedAt: 1_000,
|
||||
});
|
||||
const odd = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
|
||||
receivedAt: 1_500,
|
||||
});
|
||||
updateAircraftTrack(tracks, even);
|
||||
updateAircraftTrack(tracks, odd);
|
||||
// 20 s later the pair has expired and there is no receiver location.
|
||||
const later = decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
|
||||
receivedAt: 20_000,
|
||||
});
|
||||
const track = updateAircraftTrack(tracks, later, null);
|
||||
assert.equal(track.lastPositionAt, 20_000);
|
||||
assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001);
|
||||
assert.ok(Math.abs(track.longitude - 3.93891) < 0.0001);
|
||||
});
|
||||
|
||||
test('three refused global fixes in a row re-anchor the track', () => {
|
||||
const tracks = new Map();
|
||||
const stats = {};
|
||||
updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'), {
|
||||
receivedAt: 1_000,
|
||||
}),
|
||||
);
|
||||
updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(fromHex('8D40621D58C386435CC412692AD6'), {
|
||||
receivedAt: 1_500,
|
||||
}),
|
||||
);
|
||||
// Austin pairs relabelled as this aircraft: global decodes 8,000 km away.
|
||||
const austin = timed.frames
|
||||
.map(([, hex]) => decodeAdsbMessage(fromHex(hex)))
|
||||
.filter((message) => message?.icao === 'A0B702' && message.cpr);
|
||||
const firstOdd = austin.find((message) => message.cpr.odd);
|
||||
const firstEven = austin.find((message) => !message.cpr.odd);
|
||||
let at = 2_000;
|
||||
const feed = (message) => {
|
||||
at += 500;
|
||||
return updateAircraftTrack(
|
||||
tracks,
|
||||
{
|
||||
...message,
|
||||
icao: '40621D',
|
||||
receivedAt: at,
|
||||
cpr: { ...message.cpr, receivedAt: at },
|
||||
},
|
||||
null,
|
||||
stats,
|
||||
);
|
||||
};
|
||||
feed(firstEven);
|
||||
let track = feed(firstOdd);
|
||||
assert.equal(track.rejectedPositions, 2);
|
||||
assert.ok(Math.abs(track.latitude - 52.26578) < 0.0001, 'still anchored');
|
||||
track = feed(firstEven);
|
||||
assert.equal(stats.positionsRejected, 2, 'the third global fix re-anchors');
|
||||
assert.ok(Math.abs(track.latitude - 30.2) < 0.2, 'moved to the new stream');
|
||||
assert.equal(track.rejectStreak, 0);
|
||||
});
|
||||
|
||||
// Surface position examples from "The 1090MHz Riddle" (J. Sun), surface
|
||||
// position chapter: ICAO C8200A, even 8CC8200A3AC8F009BCDEF2..., odd
|
||||
// 8FC8200A3AB8F5F893096B..., receiver (-43.496, 172.558); published result
|
||||
// (-43.48564, 172.53942). The book prints the parity field as zeros, so the
|
||||
// 24-bit parity is recomputed here to make the frames CRC-valid.
|
||||
function withParity(hex) {
|
||||
const bytes = fromHex(hex);
|
||||
// The Mode S parity is the CRC remainder of the first 88 bits.
|
||||
const parity = modeSChecksum(bytes.slice(0, 11));
|
||||
bytes[11] = (parity >> 16) & 0xff;
|
||||
bytes[12] = (parity >> 8) & 0xff;
|
||||
bytes[13] = parity & 0xff;
|
||||
assert.equal(modeSChecksum(bytes), 0);
|
||||
return bytes;
|
||||
}
|
||||
const SURFACE_EVEN = withParity('8CC8200A3AC8F009BCDEF2000000');
|
||||
const SURFACE_ODD = withParity('8FC8200A3AB8F5F893096B000000');
|
||||
const SURFACE_RECEIVER = { latitude: -43.496, longitude: 172.558 };
|
||||
|
||||
test('surface position messages decode movement, track and ground state', () => {
|
||||
// Odd frame ME field, read by hand: TC 00111 (7), MOV 0101011 (43),
|
||||
// S 1, TRK 0001111 (15), T 0, F 1. MOV 39–93 is 15 kt + 1 kt per step
|
||||
// (43 → 19 kt); TRK is in 360/128° steps (15 → 42.1875°).
|
||||
const odd = decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 });
|
||||
assert.equal(odd.icao, 'C8200A');
|
||||
assert.equal(odd.typeCode, 7);
|
||||
assert.equal(odd.onGround, true);
|
||||
assert.equal(odd.speedKt, 19);
|
||||
assert.equal(odd.headingDeg, 42.1875);
|
||||
assert.equal(odd.altitudeFt, null, 'no altitude on the surface');
|
||||
assert.equal(odd.cpr.surface, true);
|
||||
assert.equal(odd.cpr.odd, true);
|
||||
// Even frame: MOV 0101100 (44 → 20 kt), same track.
|
||||
const even = decodeAdsbMessage(SURFACE_EVEN);
|
||||
assert.equal(even.speedKt, 20);
|
||||
assert.equal(even.cpr.odd, false);
|
||||
|
||||
// A real Schiphol frame with a valid parity (Riddle ground-speed example):
|
||||
// MOV 0101010 (42 → 18 kt), S 1, TRK 0110010 (50 → 140.625°).
|
||||
const schiphol = decodeAdsbMessage(fromHex('8C4841753AAB238733C8CD4020B1'));
|
||||
assert.equal(schiphol.icao, '484175');
|
||||
assert.equal(schiphol.speedKt, 18);
|
||||
assert.equal(schiphol.headingDeg, 140.625);
|
||||
|
||||
// MOV 1 is "stopped"; MOV 0 and a track status of 0 carry no value.
|
||||
const stopped = withParity('8CC8200A3810F009BCDEF2000000');
|
||||
const still = decodeAdsbMessage(stopped);
|
||||
assert.equal(still.speedKt, 0);
|
||||
assert.equal(still.headingDeg, undefined, 'track status 0: no track');
|
||||
});
|
||||
|
||||
test('a surface even/odd pair decodes to the published position and marks the aircraft on the ground', () => {
|
||||
const tracks = new Map();
|
||||
// Airborne first: the landing must clear the old altitude.
|
||||
const airborne = decodeAdsbMessage(fromHex('8D40621D58C382D690C8AC2863A7'));
|
||||
updateAircraftTrack(
|
||||
tracks,
|
||||
// Its altitude only: the CPR belongs to another aircraft.
|
||||
{ ...airborne, icao: 'C8200A', receivedAt: 0, cpr: null },
|
||||
SURFACE_RECEIVER,
|
||||
);
|
||||
updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }),
|
||||
SURFACE_RECEIVER,
|
||||
);
|
||||
const track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }),
|
||||
SURFACE_RECEIVER,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(track.latitude - -43.48564) < 0.00001,
|
||||
`${track.latitude}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(track.longitude - 172.53942) < 0.00001,
|
||||
`${track.longitude}`,
|
||||
);
|
||||
assert.equal(track.lastPositionAt, 2_000);
|
||||
assert.equal(track.onGround, true);
|
||||
assert.equal(track.altitudeFt, null);
|
||||
assert.equal(track.speedKt, 19);
|
||||
const record = recordFromDecoderTrack(track);
|
||||
assert.equal(record.onGround, true);
|
||||
assert.equal(record.altitudeFt, null);
|
||||
assert.ok(Math.abs(record.lat - -43.48564) < 0.00001);
|
||||
|
||||
// An airborne position afterwards clears the ground state.
|
||||
const departed = updateAircraftTrack(
|
||||
tracks,
|
||||
{ ...airborne, icao: 'C8200A', receivedAt: 3_000, cpr: null },
|
||||
SURFACE_RECEIVER,
|
||||
);
|
||||
assert.equal(departed.onGround, false);
|
||||
assert.equal(departed.altitudeFt, 38_000);
|
||||
});
|
||||
|
||||
test('a surface track is seeded by a pair; a single frame then decodes against its own fix', () => {
|
||||
// The receiver is not established to be within 45 NM of the aircraft, so a
|
||||
// lone surface frame does not decode against it (even though this one is).
|
||||
const receiver = { latitude: -43.5, longitude: 172.5 };
|
||||
const tracks = new Map();
|
||||
let track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 1_000 }),
|
||||
receiver,
|
||||
);
|
||||
assert.equal(track.lastPositionAt, null, 'no single-frame receiver fix');
|
||||
// The even/odd pair seeds the track at the published position.
|
||||
track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 2_000 }),
|
||||
receiver,
|
||||
);
|
||||
assert.ok(Math.abs(track.latitude - -43.48564) < 0.00001);
|
||||
assert.ok(Math.abs(track.longitude - 172.53942) < 0.00001);
|
||||
// With no receiver location, the aircraft's own recent fix is the reference
|
||||
// (the other frame of the pair is too old to pair with).
|
||||
track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SURFACE_EVEN, { receivedAt: 40_000 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(track.lastPositionAt, 40_000);
|
||||
assert.ok(Math.abs(track.latitude - -43.4856) < 0.001);
|
||||
assert.ok(Math.abs(track.longitude - 172.5394) < 0.001);
|
||||
// An own fix the aircraft may have left by 45 NM or more (unknown speed,
|
||||
// four minutes old) is no single-frame reference either.
|
||||
tracks.set('C8200A', { ...track, speedKt: null, category: null });
|
||||
track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 280_000 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(track.lastPositionAt, 40_000, 'reference too old to trust');
|
||||
// Without any reference a lone surface frame is ambiguous: no position.
|
||||
const blind = updateAircraftTrack(
|
||||
new Map(),
|
||||
decodeAdsbMessage(SURFACE_ODD, { receivedAt: 1_000 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(blind.lastPositionAt, null);
|
||||
assert.equal(blind.onGround, true);
|
||||
});
|
||||
|
||||
// "The 1090MHz Riddle", surface position chapter: Schiphol pair of ICAO
|
||||
// 484175, published position about (52.3206, 4.7347).
|
||||
const SCHIPHOL_EVEN = fromHex('8C4841753AAB238733C8CD4020B1');
|
||||
const SCHIPHOL_ODD = fromHex('8C4841753A9A153237AEF0F275BE');
|
||||
|
||||
test('a far receiver never places a lone surface frame; the next valid pair seeds the track', () => {
|
||||
// About 80 NM from the airport: beyond half a surface zone (45 NM).
|
||||
const receiver = { latitude: 51, longitude: 4.375 };
|
||||
const tracks = new Map();
|
||||
let track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SCHIPHOL_EVEN, { receivedAt: 1_000 }),
|
||||
receiver,
|
||||
);
|
||||
assert.equal(track.lastPositionAt, null, 'no position from one frame');
|
||||
assert.equal(track.latitude, null, 'never (50.823040, 4.602622)');
|
||||
track = updateAircraftTrack(
|
||||
tracks,
|
||||
decodeAdsbMessage(SCHIPHOL_ODD, { receivedAt: 2_000 }),
|
||||
receiver,
|
||||
);
|
||||
assert.equal(track.lastPositionAt, 2_000);
|
||||
assert.equal(track.rejectedPositions, 0, 'the valid pair is accepted');
|
||||
assert.ok(Math.abs(track.latitude - 52.3206) < 0.002, `${track.latitude}`);
|
||||
assert.ok(Math.abs(track.longitude - 4.7347) < 0.002, `${track.longitude}`);
|
||||
assert.equal(track.onGround, true);
|
||||
});
|
||||
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user