Merge main into pr-688

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-09-24 22:17:12 -07:00
commit 9b1c75e539
355 files changed
+68071 -1116

No files matched your search

+14
View File
@@ -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,
+25
View File
@@ -1600,6 +1600,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
}
});
for (const card of ['live', 'stale', 'absent', 'covered']) {
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
const id = 'cyclone:fixture:cone:0';
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
const harness = installWireHarness({ id });
const requests = [];
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
if (card === 'covered') host.hitTest = (_x, _y, options) => {
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
};
_setVesselOverlayHostForTest(host);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(requests.length, card === 'live' ? 1 : 0);
} finally {
_setVesselOverlayHostForTest(null);
harness.cleanup();
unregisterPickOwner('weather-cyclones');
}
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
+57 -3
View File
@@ -18,12 +18,13 @@
*
* 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
*/
import { feedProvenanceEnvelope } from './layerSnapshot.js';
import { pointInRing } from './naturalEarthRegions.js';
/** Layers the engine understands, with the fields queries may reference. */
@@ -61,6 +62,26 @@ export const ANALYST_LAYERS = {
text: ['place'],
flags: [],
},
satellites: {
numeric: ['altitudeM', 'speedMps'],
text: ['name', 'noradId', 'satelliteClass', 'group'],
flags: [],
},
'local-datacenters': {
numeric: [],
text: ['name', 'operator', 'capacity'],
flags: [],
},
'local-dams': {
numeric: [],
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;
@@ -163,12 +184,15 @@ export function createAnalystEngine(providers) {
// 1) Source records
let records;
let layersQueried;
let queriedSnapshots;
if (layers === null) {
records = lastResult.items.slice();
layersQueried = lastResult.coverage.layersQueried;
queriedSnapshots = lastResult.coverage.feedProvenance?.layers || [];
} else {
records = [];
layersQueried = [];
queriedSnapshots = [];
const unknown = layers.filter((k) => !ANALYST_LAYERS[k]);
if (unknown.length) {
return {
@@ -180,7 +204,22 @@ export function createAnalystEngine(providers) {
for (const key of layers) {
if (!ANALYST_LAYERS[key]) continue;
const rows = providers.getRecords(key) || [];
layersQueried.push({ layerKey: key, records: rows.length });
const snapshot = providers.getLayerSnapshot?.(key);
if (snapshot) queriedSnapshots.push(snapshot);
layersQueried.push({
layerKey: key,
records: rows.length,
...(snapshot
? {
feedState: snapshot.feedState,
source: snapshot.source,
lastUpdate: snapshot.lastUpdate,
enabled: snapshot.enabled,
error: snapshot.error,
}
: {}),
...providers.getRecordCoverage?.(key, rows),
});
for (const row of rows) records.push({ layerKey: key, ...row });
}
}
@@ -195,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,
@@ -292,8 +339,15 @@ export function createAnalystEngine(providers) {
coverage: {
layersQueried,
scope: scopeNote,
...(queriedSnapshots.length
? { feedProvenance: feedProvenanceEnvelope(queriedSnapshots) }
: {}),
followUp: Boolean(spec.followUp && lastResult),
note: 'client-side data only — answers cover what the enabled layers currently hold',
note: layersQueried.some(
(layer) => layer.basis === 'bounded-loaded-records',
)
? 'Counts and ranks cover the bounded examined loaded records only; omitted records may change the nearest item or count. Satellite distance is ground distance, not slant range.'
: 'client-side data only — answers cover what the enabled layers currently hold',
},
// Surfaced so the narration can name the centre it measured from rather
// than implying a view-centred answer.
+92
View File
@@ -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' },
@@ -195,9 +208,88 @@ test('analyst: route fields queryable from cached enrichment only', async () =>
assert.deepEqual(r.items.map((i) => i.id), ['SWA1'], 'null route fields never match');
});
test('analyst: satellites and local infrastructure are queryable layers', async () => {
const SATS = [
{ id: 'ISS (ZARYA)', noradId: '25544', name: 'ISS (ZARYA)', lat: 30.3, lon: -97.7, altitudeM: 410000, satelliteClass: 'STATION · ISS', group: 'stations' },
{ id: 'GPS BIIR-2', noradId: '24876', name: 'GPS BIIR-2', lat: 51.0, lon: 0.0, altitudeM: 20200000, satelliteClass: 'NAV · GPS', group: 'gps-ops' },
];
const DAMS = [
{ id: 'Austin Dam', name: 'Austin Dam', lat: 30.27, lon: -97.74, operator: 'LCRA', river: 'Colorado', output: '2 MW' },
{ id: 'Far Dam', name: 'Far Dam', lat: 45.0, lon: -122.0, operator: 'USACE', river: 'Columbia', output: '1000 MW' },
];
const DCS = [
{ id: 'AUS-1', name: 'AUS-1', lat: 30.28, lon: -97.75, operator: 'Example Cloud', capacity: '27 MW' },
];
const eng = createAnalystEngine({
getRecords: (key) => ({ satellites: SATS, 'local-dams': DAMS, 'local-datacenters': DCS }[key] || []),
resolveRegionRing: async (name) => (/texland/i.test(name) ? TEXLAND : null),
getViewContext: () => ({ lat: 30.27, lon: -97.74, viewRadiusKm: 150 }),
});
const sats = await eng.query({
layers: ['satellites'], scope: { kind: 'view' }, sortBy: 'distance', limit: 2,
});
assert.equal(sats.ok, true);
assert.equal(sats.count, 1, 'GPS sat is out of the Austin view radius');
assert.equal(sats.items[0].noradId, '25544');
assert.ok(Number.isFinite(sats.items[0].distanceKm));
const nav = await eng.query({
layers: ['satellites'], scope: { kind: 'anywhere' },
filters: [{ field: 'satelliteClass', op: 'contains', value: 'NAV' }],
});
assert.deepEqual(nav.items.map((i) => i.id), ['GPS BIIR-2']);
const dams = await eng.query({
layers: ['local-dams'], scope: { kind: 'view' }, sortBy: 'distance', limit: 5,
});
assert.equal(dams.count, 1);
assert.equal(dams.items[0].id, 'Austin Dam');
assert.equal(dams.items[0].river, 'Colorado');
const dcs = await eng.query({
layers: ['local-datacenters'], scope: { kind: 'region', name: 'Texland' },
filters: [{ field: 'operator', op: 'contains', value: 'cloud' }],
});
assert.equal(dcs.count, 1);
assert.equal(dcs.items[0].id, 'AUS-1');
});
test('helpers: haversine sanity + scope radius', () => {
const km = haversineKm(30.2672, -97.7431, 29.7604, -95.3698); // Austin→Houston
assert.ok(km > 200 && km < 280, `Austin-Houston ~235km, got ${km}`);
const scoped = applyScope(FLIGHTS, { kind: 'radius' }, { center: { lat: 30.27, lon: -97.74 }, km: 50 });
assert.deepEqual(scoped.map((f) => f.id).sort(), ['GND1', 'SWA1']);
});
test('bounded loaded cohorts disclose truncation before filtering and retain it on follow-up', async () => {
const engine = createAnalystEngine({
getRecords: () => [{ id: 'sample dam', lat: 0, lon: 0, name: 'sample' }],
getRecordCoverage: () => ({ basis: 'bounded-loaded-records', recordsExamined: 1, loadedCount: 3000, sourceTruncated: true }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const result = await engine.query({ layers: ['local-dams'], scope: { kind: 'anywhere' }, sortBy: 'distance' });
assert.equal(result.count, 1);
assert.equal(result.coverage.layersQueried[0].sourceTruncated, true);
assert.match(result.coverage.note, /omitted records may change the nearest item or count/);
const followUp = await engine.query({ followUp: true });
assert.equal(followUp.coverage.layersQueried[0].loadedCount, 3000);
});
test('follow-up provenance stays attached to old rows after a feed recovers', async () => {
let state = 'stale';
const engine = createAnalystEngine({
getRecords: () => [{ id: 'A', lat: 0, lon: 0 }],
getLayerSnapshot: () => ({ id: 'flights', enabled: true, feedState: state }),
getViewContext: () => ({ lat: 0, lon: 0, viewRadiusKm: 25 }),
});
const first = await engine.query();
state = 'nominal';
const followUp = await engine.query({ followUp: true });
assert.equal(first.coverage.feedProvenance.overall, 'stale');
assert.equal(followUp.coverage.feedProvenance.overall, 'stale');
engine.reset();
assert.equal(engine.hasMemory(), false);
assert.equal((await engine.query()).coverage.feedProvenance.overall, 'nominal');
});
+32 -136
View File
@@ -22,6 +22,8 @@ import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -964,10 +966,7 @@ export function createBhoteKoshiEventLayer({
let _enabled = false;
let _panel = null;
let _panelRefs = null;
let _splitLine = null;
let _splitHandle = null;
let _splitDragging = false;
let _splitPointerId = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
let _imageryLayerCollection = null;
@@ -1022,14 +1021,11 @@ export function createBhoteKoshiEventLayer({
let _playStartedAt = 0;
let _playStartProgress = 0;
let _animationFrame = null;
let _previousMapStack = null;
let _eventMapGeneration = null;
let _previousSplitPosition = null;
let _comparisonLease = null;
let _introTimer = null;
let _introGeneration = 0;
let _lastSurgePosition = null;
let _lastSurgeVisible = null;
let _lastSplitCssValue = null;
let _presentation = 'standalone';
let _sceneBeatId = null;
let _sceneBeatReveal = 0.36;
@@ -2556,79 +2552,20 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
}
function ensureSplitLine(event) {
_splitLine = document.createElement('div');
_splitLine.id = 'bhote-koshi-split-line';
const beforeLabel = document.createElement('span');
const afterLabel = document.createElement('span');
const splitHandle = document.createElement('button');
beforeLabel.className = 'before';
afterLabel.className = 'after';
beforeLabel.textContent = 'A';
beforeLabel.title = event.imagery.before.label;
afterLabel.textContent = 'B';
afterLabel.title = event.imagery.after.label;
splitHandle.type = 'button';
splitHandle.className = 'bhote-koshi-split-handle';
splitHandle.setAttribute('role', 'slider');
splitHandle.setAttribute(
'aria-label',
'Historical reference and post-event image divider',
);
splitHandle.setAttribute('aria-valuemin', '0');
splitHandle.setAttribute('aria-valuemax', '100');
splitHandle.setAttribute('aria-orientation', 'horizontal');
splitHandle.textContent = '↔';
splitHandle.addEventListener('pointerdown', handleSplitPointerDown);
splitHandle.addEventListener('pointermove', handleSplitPointerMove);
splitHandle.addEventListener('pointerup', finishSplitPointerDrag);
splitHandle.addEventListener('pointercancel', finishSplitPointerDrag);
splitHandle.addEventListener('keydown', handleSplitKeyDown);
_splitHandle = splitHandle;
_splitLine.append(beforeLabel, splitHandle, afterLabel);
document.body.appendChild(_splitLine);
}
function setSplitFromClientX(clientX) {
const viewportWidth =
Number(document.documentElement?.clientWidth) ||
Number(window.innerWidth) ||
0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
setSplit(Number(clientX) / viewportWidth);
}
function handleSplitPointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
_splitDragging = true;
_splitPointerId = event.pointerId ?? null;
_splitHandle?.setPointerCapture?.(event.pointerId);
setSplitFromClientX(event.clientX);
}
function handleSplitPointerMove(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
event.preventDefault?.();
setSplitFromClientX(event.clientX);
}
function finishSplitPointerDrag(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
_splitHandle?.releasePointerCapture?.(event.pointerId);
_splitDragging = false;
_splitPointerId = null;
}
function handleSplitKeyDown(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') setSplit(0);
else if (event.key === 'End') setSplit(1);
else setSplit(_split + (event.key === 'ArrowRight' ? step : -step));
function createSplitControl(event) {
_splitControl = createImagerySplit({
scene: _viewer.scene,
initialValue: _split,
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
onChange: setSplit,
});
}
function applyCinematicCamera() {
@@ -3211,7 +3148,7 @@ export function createBhoteKoshiEventLayer({
_panelRefs.observedStatus.hidden = !imageryAvailable;
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
if (_splitLine) _splitLine.hidden = !imageryAvailable;
_splitControl?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3328,24 +3265,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
if (_viewer && _viewer.scene.splitPosition !== _split)
_viewer.scene.splitPosition = _split;
const cssValue = `${_split * 100}%`;
if (_lastSplitCssValue !== cssValue) {
document.documentElement.style.setProperty(
'--bhote-koshi-split',
cssValue,
);
_lastSplitCssValue = cssValue;
}
if (_splitHandle) {
const beforePercent = Math.round(_split * 100);
_splitHandle.setAttribute('aria-valuenow', String(beforePercent));
_splitHandle.setAttribute(
'aria-valuetext',
`A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`,
);
}
_splitControl?.setValue(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3428,7 +3348,6 @@ export function createBhoteKoshiEventLayer({
async function enable(viewer, { origin = 'local-restore', signal } = {}) {
_viewer = viewer;
_enabled = true;
_previousSplitPosition = viewer.scene.splitPosition;
// Public lifecycle passes signal, not intent origin, to enable. Its atomic
// restore has already applied the scene parameters; those own presentation.
// A missing origin is passive, never authority to focus or start playback.
@@ -3437,24 +3356,20 @@ export function createBhoteKoshiEventLayer({
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
_previousMapStack = _mapStackController?.getActiveId?.() || null;
if (
!sceneDirected &&
_mapStackController &&
_previousMapStack !== 'esri-imagery'
) {
await _mapStackController.setStack('esri-imagery');
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
switchPolicy: sceneDirected ? 'preserve' : 'esri',
});
await _comparisonLease.ready;
}
_eventMapGeneration = sceneDirected
? null
: (_mapStackController?.getSwitchGeneration?.() ?? null);
throwIfEnableCancelled(signal, _enabled);
await createImagery(event);
throwIfEnableCancelled(signal, _enabled);
await syncTerrainComparison();
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
ensureSplitLine(event);
createSplitControl(event);
const panelView = createPanel(event, {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
@@ -3573,17 +3488,8 @@ export function createBhoteKoshiEventLayer({
?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
_splitDragging = false;
_splitPointerId = null;
_splitLine?.remove();
_splitLine = null;
_splitHandle = null;
document.documentElement.style.removeProperty('--bhote-koshi-split');
_lastSplitCssValue = null;
if (_viewer?.scene && _previousSplitPosition != null) {
_viewer.scene.splitPosition = _previousSplitPosition;
}
_previousSplitPosition = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
_split = 0.5;
_presentation = 'standalone';
@@ -3601,20 +3507,10 @@ export function createBhoteKoshiEventLayer({
cancel?.(_sceneSeekFrame);
_sceneSeekFrame = null;
}
const previousMapStack = _previousMapStack;
const eventMapGeneration = _eventMapGeneration;
_previousMapStack = null;
_eventMapGeneration = null;
const comparisonLease = _comparisonLease;
_comparisonLease = null;
try {
if (
_mapStackController &&
previousMapStack &&
previousMapStack !== 'esri-imagery' &&
_mapStackController.getActiveId?.() === 'esri-imagery' &&
_mapStackController.getSwitchGeneration?.() === eventMapGeneration
) {
await _mapStackController.setStack(previousMapStack);
}
await comparisonLease?.release();
} finally {
renderHost.request('bhote-koshi-disable');
}
+29
View File
@@ -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();
}
+36
View File
@@ -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/,
);
});
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHlsPuller,
fetchHlsBytes,
parseHlsMedia,
HLS_LIMITS,
} from '../../server/providers/cctv/stream.js';
const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
[12, 13, 14],
);
for (const bad of [
playlist.replace('a.ts', 'https://evil.example/a.ts'),
playlist.replace('a.ts', '//evil.example/a.ts'),
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
playlist.replace('12', '-1'),
playlist.replace('2,', 'Infinity,'),
])
assert.throws(() => parseHlsMedia(bad, base));
});
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
let init;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async (_url, options) => {
init = options;
return new Response('', {
status: 302,
headers: { Location: 'https://evil.example' },
});
},
}),
);
assert.equal(init.redirect, 'error');
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () => new Response('abcdef'),
}),
);
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () =>
new Response('a', { headers: { 'content-length': '100' } }),
}),
);
});
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
const manager = createHlsPuller({
limits: {
...HLS_LIMITS,
sessions: 1,
segmentBytes: 4,
sessionBytes: 6,
segments: 2,
pollMs: 100000,
},
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const [a, b] = await Promise.all([
manager.ensure('a', base),
manager.ensure('a', base),
]);
assert.equal(a, b);
await assert.rejects(manager.ensure('b', base));
assert.equal(await manager.waitReady(a), true);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
const text = await manager.buildPlaylist(a, 'a');
assert.match(text, /seg_0\.ts\?session=/);
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
manager.stop('a', 'stale-token');
assert.equal(manager.stats().sessions, 1);
manager.stop('a', a.token);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
let observed;
const manager = createHlsPuller({
fetchImpl: async (_url, { signal }) => {
observed = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true,
}),
);
},
});
const entry = await manager.ensure('a', base);
await manager.shutdown();
assert.equal(observed.aborted, true);
assert.equal(entry.stopping, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await assert.rejects(manager.ensure('a', base));
});
test('idle cleanup stops all polling without a background sweep', async () => {
let calls = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
fetchImpl: async (url) => {
calls++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
await manager.ensure('a', base);
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
const stoppedCalls = calls;
await new Promise((r) => setTimeout(r, 30));
assert.equal(calls, stoppedCalls);
await manager.shutdown();
});
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
const before = await manager.buildPlaylist(entry, 'a');
assert.match(before, /seg_0\.ts/);
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
await new Promise((r) => setTimeout(r, 30));
const after = await manager.buildPlaylist(entry, 'a');
assert.match(
after,
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('upstream discontinuity tags survive the media parser', () => {
const parsed = parseHlsMedia(
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
base,
);
assert.equal(parsed[0].discontinuity, false);
assert.equal(parsed[1].discontinuity, true);
});
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
current = playlist.replaceAll('.ts', '.ts?generation=2');
await new Promise((r) => setTimeout(r, 30));
assert.match(
await manager.buildPlaylist(entry, 'a'),
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
let downloads = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
fetchImpl: async (url) => {
downloads++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
const [a, b] = await Promise.all([
manager.ensure('camera', base, 'viewer-a'),
manager.ensure('camera', base, 'viewer-b'),
]);
assert.equal(a, b);
await manager.waitReady(a);
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
assert.equal(a.leases.size, 2);
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
manager.release('camera', 'viewer-a');
assert.equal(manager.stats().sessions, 1);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
assert.match(
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
/lease=viewer-b/,
);
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
assert.equal(b.controller.signal.aborted, false);
manager.release('camera', 'viewer-b');
assert.equal(b.controller.signal.aborted, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const entry = await manager.ensure('camera', base, 'abandoned');
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
assert.equal(entry.leases.has('abandoned'), false);
assert.equal(entry.leases.has('active'), true);
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
+454
View File
@@ -0,0 +1,454 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
cycloneProxy,
parseCycloneStatus,
attachCycloneGeometry,
} from '../../server/providers/cyclones.js';
const NOW = Date.UTC(2026, 8, 16, 3, 35);
const TIME = '2026-09-16T03:00:00.000Z';
function status(overrides = {}) {
return {
activeStorms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
intensity: '25',
pressure: '1006',
longitudeNumeric: -125.8,
latitudeNumeric: 15.5,
movementDir: 255,
movementSpeed: 12,
lastUpdate: TIME,
forecastAdvisory: {
advNum: '010',
issuance: TIME,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
...overrides,
},
],
};
}
function collections(adv = '10') {
const geometry = [
{ type: 'Point', coordinates: [-125.8, 15.5] },
{
type: 'LineString',
coordinates: [
[-125.8, 15.5],
[-128, 15],
],
},
{
type: 'Polygon',
coordinates: [
[
[-126, 15],
[-128, 15],
[-127, 17],
[-126, 15],
],
],
},
];
return geometry.map((shape, index) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
advisnum: adv,
tau: 0,
maxwind: 25,
gust: 9999,
},
geometry: shape,
},
],
}));
}
function install(options = {}, preview = false) {
let handler;
const plugin = cycloneProxy({ now: () => NOW, ...options });
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/cyclones');
handler = callback;
},
},
});
const begin = (url = '/', method = 'GET') => {
const res = new EventEmitter();
res.writeHead = (code, headers) => {
res.statusCode = code;
res.headers = headers;
};
res.end = (body) => {
res.body = body;
};
return { res, done: handler({ url, method }, res).then(() => res) };
};
return { begin, request: (url, method) => begin(url, method).done };
}
const body = (res) => JSON.parse(res.body);
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
function fetcher(calls = [], data = status(), parts = collections()) {
return async (url, options) => {
calls.push({ url: new URL(url), options });
return Response.json(
url.includes('CurrentStorms')
? data
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
);
};
}
test('status retains official clocks, knots, source links and finite current position', () => {
const storm = parseCycloneStatus(status(), NOW)[0];
assert.equal(storm.advisoryNumber, '10');
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
const missing = parseCycloneStatus(
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
NOW,
)[0];
assert.equal(missing.windKt, null);
assert.equal(missing.pressureHpa, null);
assert.equal(missing.movement.speedKt, null);
assert.equal(
parseCycloneStatus(
status({
forecastAdvisory: {
advNum: '10',
issuance: TIME,
url: 'https://www.nhc.noaa.gov.evil/secret',
},
}),
NOW,
)[0].advisoryUrl,
null,
);
});
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
for (const payload of [
{},
{ activeStorms: null },
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
status({ id: 'wp012026' }),
status({ name: 'a'.repeat(81) }),
status({ name: '<script>' }),
status({ longitudeNumeric: Infinity }),
status({ latitudeNumeric: 91 }),
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
status({
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
}),
])
assert.throws(() => parseCycloneStatus(payload, NOW));
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
parts[1].features[0].geometry.coordinates = [
[179, 15],
[-179, 16],
];
parts[2].features[0].geometry.coordinates.push([
[-126.5, 15.5],
[-127, 15.5],
[-126.8, 16],
[-126.5, 15.5],
]);
const good = attachCycloneGeometry(storms, parts)[0];
assert.equal(good.geometryStatus, 'current');
assert.equal(good.geometryAdvisoryNumber, '10');
assert.deepEqual(good.track, parts[1].features[0].geometry);
assert.equal(good.cone.coordinates.length, 2);
assert.equal(good.forecastPoints[0].gustKt, null);
assert.equal(good.forecastPoints[0].tauHours, 0);
assert.equal(
'validAt' in good.forecastPoints[0],
false,
'do not fabricate point time from advisory + tau',
);
const old = attachCycloneGeometry(storms, collections('9'))[0];
assert.equal(old.geometryStatus, 'pending');
assert.equal(old.track, null);
assert.equal(old.cone, null);
assert.deepEqual(old.forecastPoints, []);
assert.deepEqual(old.position, storms[0].position);
const mixed = collections();
mixed[2] = collections('9')[2];
const partial = attachCycloneGeometry(storms, mixed)[0];
assert.equal(partial.geometryStatus, 'pending');
assert.equal(
partial.track,
null,
'a new track and old cone cannot form one advisory snapshot',
);
});
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
const storms = parseCycloneStatus(status(), NOW);
const mutations = [
(p) => {
p[0].exceededTransferLimit = true;
},
(p) => {
p[0].features = Array(501).fill(p[0].features[0]);
},
(p) => {
p[1].features.push(p[1].features[0]);
},
(p) => {
p[0].features.push(p[0].features[0]);
},
(p) => {
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
},
(p) => {
p[1].features[0].geometry.coordinates[1] = [181, 1];
},
(p) => {
p[2].features[0].geometry.coordinates[0].pop();
},
(p) => {
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
},
(p) => {
p[1].features[0].geometry = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
};
},
(p) => {
p[2].features[0].geometry = {
type: 'GeometryCollection',
geometries: [],
};
},
];
for (const mutate of mutations) {
const parts = collections();
mutate(parts);
assert.throws(() => attachCycloneGeometry(storms, parts));
}
});
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
for (const preview of [false, true]) {
const calls = [];
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
const snapshot = body(await request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.stale, false);
assert.match(snapshot.coverage, /not worldwide/);
assert.equal(snapshot.storms[0].geometryStatus, 'current');
assert.equal(snapshot.fetchedAt, NOW);
assert.equal(calls.length, 4);
assert.equal(
calls[0].url.href,
'https://www.nhc.noaa.gov/CurrentStorms.json',
);
for (const call of calls) assert.equal(call.options.redirect, 'error');
for (const call of calls.slice(1)) {
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
assert.equal(call.url.searchParams.get('outSR'), '4326');
assert.equal(call.url.searchParams.get('f'), 'geojson');
assert.notEqual(call.url.searchParams.get('outFields'), '*');
}
assert.equal(
calls[2].url.searchParams.get('outFields'),
'idp_source,advisnum',
);
assert.deepEqual(body(await request()), snapshot);
assert.equal(calls.length, 4);
}
});
test('query/method rejection cannot redirect or widen provider work', async () => {
let calls = 0;
const { request } = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const url of [
'/?url=https://evil',
'/?storm=ep152026',
'/other',
'/?bbox=0,0,1,1',
])
assert.equal((await request(url)).statusCode, 400);
assert.equal((await request('/', 'POST')).statusCode, 405);
assert.equal(calls, 0);
});
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
const calls = [];
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
assert.deepEqual(body(await empty.request()).storms, []);
assert.equal(body(await empty.request()).unavailable, false);
assert.equal(calls.length, 1);
const broken = install({
fetchImpl: async (url) =>
url.includes('CurrentStorms')
? Response.json(status())
: new Response('secret detail', { status: 503 }),
});
const snapshot = body(await broken.request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
assert.equal(snapshot.storms[0].track, null);
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
});
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
let clock = NOW;
let fail = false;
let calls = 0;
const good = fetcher();
const { request } = install({
now: () => clock,
fetchImpl: async (...args) => {
calls++;
if (fail) throw new Error('private');
return good(...args);
},
});
await request();
clock += 299_000;
await request();
assert.equal(calls, 4);
fail = true;
clock += 2000;
const stale = body(await request());
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, NOW);
assert.equal(stale.storms[0].issuedAt, TIME);
await request();
assert.equal(calls, 5);
clock += 12 * 3600_000;
const expired = body(await request());
assert.equal(expired.unavailable, true);
assert.deepEqual(expired.storms, []);
});
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
let release;
let signal;
const good = fetcher();
const { begin } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms')) {
signal = options.signal;
await new Promise((resolve, reject) => {
release = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
});
});
}
return good(url, options);
},
});
const a = begin();
const b = begin();
await nextTurn();
a.res.emit('close');
await a.done;
assert.equal(signal.aborted, false);
assert.equal(a.res.body, undefined);
release();
assert.equal(body(await b.done).unavailable, false);
let calls = 0;
const cancel = install({
fetchImpl: async (url, options) => {
calls++;
if (calls > 1) return good(url, options);
signal = options.signal;
return new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
},
});
const c = cancel.begin();
await nextTurn();
c.res.emit('close');
await c.done;
assert.equal(signal.aborted, true);
assert.equal(
body(await cancel.request()).unavailable,
false,
'cancellation does not impose upstream failure cooldown',
);
});
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
for (const stalled of [false, true]) {
let cancelled = false;
const { request } = install({
timeoutMs: 15,
fetchImpl: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
},
cancel() {
cancelled = true;
},
}),
),
});
const snapshot = body(await request());
assert.equal(snapshot.unavailable, true);
assert.equal(cancelled, true);
}
});
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
let release;
const good = fetcher();
const { request } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms'))
await new Promise((resolve) => {
release = resolve;
});
return good(url, options);
},
});
const waiting = Array.from({ length: 32 }, () => request());
await nextTurn();
assert.equal((await request()).statusCode, 429);
release();
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
const redirected = install({
fetchImpl: async () =>
new Response('private', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
});
assert.equal(body(await redirected.request()).unavailable, true);
});
+49
View File
@@ -77,6 +77,23 @@ export const DATA_CREDITS = [
key: 'usgs',
html: 'Earthquakes: Data courtesy of the U.S. Geological Survey',
},
{
key: 'nasa-gibs',
html:
'Recent imagery: We acknowledge the use of imagery provided by services from ' +
"NASA's Global Imagery Browse Services (GIBS), part of NASA's Earth Science " +
'Data and Information System (ESDIS). ' +
'<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:
@@ -147,6 +164,10 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'deldot-cctv',
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
},
{
key: 'caltrans-cctv',
html:
@@ -212,6 +233,34 @@ export const DATA_CREDITS = [
'<a href="https://terrain.reearth.land" target="_blank" rel="noopener">Re:Earth Terrain</a> / ' +
'Mapterhorn (CC BY 4.0) / EGM2008 (NGA)',
},
{
key: 'weather-noaa',
html: 'Observed weather: <a href="https://nowcoast.noaa.gov/" target="_blank" rel="noopener">NOAA nowCOAST</a> · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners. <a href="https://oceanservice.noaa.gov/disclaimer.html" target="_blank" rel="noopener">Source disclaimer</a>.',
},
{
key: 'weather-cyclones',
html: 'Cyclone advisories: <a href="https://www.nhc.noaa.gov/" target="_blank" rel="noopener">NOAA/NWS NHC / CPHC</a> · Atlantic and eastern/central North Pacific. Forecast center uncertainty, not storm size.',
},
{
key: 'weather-lightning',
html: 'Lightning density: NOAA/NWS nowCOAST · derived from Vaisala NLDN/GLD360. <a href="https://ocean.weather.gov/lightning/lightning_pdd.php" target="_blank" rel="noopener">Public derived density product</a>, not raw detections.',
},
{
key: 'wind-gfs',
html:
'Wind: resampled NOAA Global Forecast System (GFS) 10 m wind via ' +
'<a href="https://registry.opendata.aws/noaa-gfs-bdp-pds/" target="_blank" rel="noopener">NOAA Open Data on AWS</a> ' +
'(U.S. public domain; forecast, not observations)',
},
{
key: 'ecmwf-ifs',
html:
'This service is based on data and products of the European Centre for Medium-Range Weather Forecasts (ECMWF). ' +
'<a href="https://www.ecmwf.int/en/forecasts/datasets/open-data" target="_blank" rel="noopener">ECMWF Open Data</a> · ' +
'<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener">CC BY 4.0</a>. ' +
'Modified: resampled 10 m IFS forecast vectors and animated display. ' +
'ECMWF does not accept any liability whatsoever for any error or omission in the data, their availability, or for any loss or damage arising from their use.',
},
// ── Bundled snapshots ───────────────────────────────────────────
{
key: 'datacenters',
+63 -4
View File
@@ -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;
+4
View File
@@ -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);
}
+14
View File
@@ -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
+9 -1
View File
@@ -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
+47
View File
@@ -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);
}
}
}
}
});
+410
View File
@@ -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);
});
+7 -1
View File
@@ -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"),
+19
View File
@@ -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'),
+4 -4
View File
@@ -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,
})));
+13
View File
@@ -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
}
]
}
+112
View File
@@ -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;
+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
+8
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
@@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', ()
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
+253
View File
@@ -0,0 +1,253 @@
import { layerFeedState } from './feedState.js';
/**
* Shared layer snapshot envelope for voice and HUD answers.
*
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
* summary must all name the same feed-state. This module is the one envelope:
* it classifies `getStats()` through `layerFeedState` and stamps the result
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
* HUD summary proxy can import it from Node.
*
* @module data/layerSnapshot
*/
/** Feed states the Data Layers chips and voice/HUD provenance share. */
export const LAYER_FEED_STATES = Object.freeze([
'nominal',
'loading',
'degraded',
'stale',
'fallback',
'unavailable',
]);
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
export const LAYER_FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/** Snapshot feed-state including a disabled layer. */
export const SNAPSHOT_FEED_STATES = Object.freeze([
...LAYER_FEED_STATES,
'off',
]);
/**
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
* neighbour.
*/
export const FEED_STATE_SEVERITY = Object.freeze({
unavailable: 0,
loading: 1,
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
});
export { layerFeedState } from './feedState.js';
function snapshotError(stats) {
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
if (err == null || err === '') return null;
return err instanceof Error ? err.message : String(err);
}
/**
* Age of a lastUpdate timestamp in whole seconds.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {number|null} Non-negative seconds, or null when unknown.
*/
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
const stamp = Number(lastUpdate);
if (!Number.isFinite(stamp) || stamp <= 0) return null;
return Math.max(0, Math.floor((now - stamp) / 1000));
}
/**
* Compact age label matching the Data Layers meta line.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
*/
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
const sec = snapshotAgeSec(lastUpdate, now);
if (sec === null) return null;
if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
let worstRank = Number.POSITIVE_INFINITY;
for (const state of states || []) {
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
const rank = FEED_STATE_SEVERITY[state];
if (rank < worstRank) {
worst = state;
worstRank = rank;
}
}
return worst;
}
/**
* One honest layer snapshot for voice/HUD narration.
*
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
* even if leftover stats would otherwise classify as stale. Counts and errors
* still travel so a disabled layer with a last-known error is inspectable.
*
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
* @param {{ now?: number }} [options]
* @returns {{
* id: string|null,
* name: string|null,
* enabled: boolean,
* feedState: string,
* source: string|null,
* count: number,
* lastUpdate: number|null,
* ageSec: number|null,
* ageLabel: string|null,
* error: string|null,
* }}
*/
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
const source = String(stats.source || layer.source || '').trim() || null;
const enabled = Boolean(layer.enabled);
const countRaw = Number(stats.count);
const count = Number.isFinite(countRaw) ? countRaw : 0;
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
? Number(stats.lastUpdate)
: null;
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
return {
id: layer.id || layer.layerKey || null,
name: layer.name || layer.label || layer.id || layer.layerKey || null,
enabled,
feedState,
source,
count,
lastUpdate,
ageSec: snapshotAgeSec(lastUpdate, now),
ageLabel: snapshotAgeLabel(lastUpdate, now),
error: snapshotError(stats),
};
}
/**
* Snapshot every layer in a roster.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {Array<object>}
*/
export function layerSnapshots(layers = [], options) {
if (!Array.isArray(layers)) return [];
return layers.map((layer) => layerSnapshot(layer, options));
}
function asSnapshot(entry, now) {
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
if (typeof entry.feedState === 'string') return entry;
return layerSnapshot(entry, { now });
}
/**
* Mechanical narration note so the model does not have to infer honesty.
* @param {Array<object>} layers Snapshots.
* @param {string|null} overall Worst considered feed-state.
* @returns {string}
*/
export function feedProvenanceNote(layers = [], overall = null) {
if (!overall) {
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
}
if (overall === 'nominal') {
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
}
if (overall === 'off') {
const names =
layers
.map((s) => s.name || s.id)
.filter(Boolean)
.join(', ') || 'the requested layer';
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
}
const flagged = layers.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
);
const bits = flagged.map((s) => {
const extras = [
s.source,
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
s.error,
]
.filter(Boolean)
.join(', ');
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
/**
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
*/
export function feedProvenanceEnvelope(
snapshots = [],
{ now = Date.now() } = {},
) {
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
asSnapshot(entry, now),
);
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
const overall =
worstFeedState(active.map((s) => s.feedState)) ||
(layers.length ? 'off' : null);
return {
overall,
layers,
note: feedProvenanceNote(layers, overall),
};
}
/**
* Compact voice-tool layer row: identity + the fields a model must read.
* @param {object} layer Raw `getAll()` row or snapshot.
* @param {{ now?: number }} [options]
* @returns {object}
*/
export function viewStateLayerRow(layer, options) {
const snap = layerSnapshot(layer, options);
return {
id: snap.id,
name: snap.name,
enabled: snap.enabled,
count: snap.count,
error: snap.error,
feedState: snap.feedState,
source: snap.source,
lastUpdate: snap.lastUpdate,
};
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
FEED_STATE_SEVERITY,
LAYER_FEED_STATE_LABELS,
LAYER_FEED_STATES,
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
feedProvenanceNote,
layerFeedState,
layerSnapshot,
layerSnapshots,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
worstFeedState,
} from './layerSnapshot.js';
const NOW = 1_700_000_000_000;
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
const src = readFileSync(
new URL('./layerSnapshot.js', import.meta.url),
'utf8',
);
assert.equal(src.includes("from './manager.js'"), false);
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
});
test('layerFeedState matches the chip classifier for every honesty class', () => {
assert.equal(
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
'unavailable',
);
assert.equal(
layerFeedState({
status: 'unavailable',
error: 'feed down',
count: 50,
lastUpdate: 1,
}),
'unavailable',
);
assert.equal(
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({
source: 'adsb.lol',
fallback: false,
count: 10,
lastUpdate: 1,
}),
'nominal',
);
assert.equal(
layerFeedState({ stale: true, count: 0, lastUpdate: 1 }),
'stale',
);
assert.equal(
layerFeedState({
error: 'partial group failure',
count: 50,
lastUpdate: 1,
}),
'degraded',
);
assert.equal(layerFeedState({ loading: true }), 'loading');
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
assert.equal(
layerFeedState({
status: 'zoom-in',
error: 'zoom in to search',
count: 12,
}),
'nominal',
);
assert.equal(
layerFeedState({ status: 'zoom-in', stale: true, count: 12 }),
'stale',
);
});
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
}
assert.equal(
FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale,
true,
);
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
assert.equal(worstFeedState([]), null);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: false,
source: 'OpenSky Network',
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'off');
assert.equal(snap.enabled, false);
assert.equal(snap.count, 12);
assert.equal(snap.ageLabel, '4m ago');
});
test('an enabled stale layer carries age, source, and a mechanical note', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'stale');
assert.equal(snap.source, 'OpenSky Network');
assert.equal(snap.ageSec, 240);
assert.equal(snap.ageLabel, '4m ago');
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
assert.equal(envelope.overall, 'stale');
assert.match(envelope.note, /Live Flights is STALE/);
assert.match(envelope.note, /OpenSky Network/);
assert.match(envelope.note, /4m ago/);
assert.match(envelope.note, /do not describe this as live/i);
});
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
const layers = layerSnapshots(
[
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: true,
stats: { count: 3, lastUpdate: NOW - 10_000 },
},
{
id: 'local-firms',
name: 'Active Fires',
enabled: true,
stats: {
error: 'FIRMS key missing',
count: 8,
lastUpdate: NOW - 30_000,
},
},
],
{ now: NOW },
);
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
assert.equal(envelope.overall, 'degraded');
assert.match(envelope.note, /Active Fires is DEGRADED/);
assert.match(envelope.note, /FIRMS key missing/);
});
test('an unavailable feed with no prior data does not become a confident zero', () => {
const snap = layerSnapshot(
{
id: 'ais-live-vessels',
name: 'Live Vessels',
enabled: true,
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
},
{ now: NOW },
);
assert.equal(snap.feedState, 'unavailable');
const envelope = feedProvenanceEnvelope([snap]);
assert.equal(envelope.overall, 'unavailable');
assert.match(envelope.note, /UNAVAILABLE/);
});
test('fallback provenance names the fallback source instead of calling it live', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
source: 'adsb.lol fallback',
count: 40,
lastUpdate: NOW - 8_000,
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'fallback');
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
});
test('view-state rows keep the existing identity fields and add feedState', () => {
const row = viewStateLayerRow(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
count: 9,
lastUpdate: 42,
error: null,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.deepEqual(row, {
id: 'flights',
name: 'Live Flights',
enabled: true,
count: 9,
error: null,
feedState: 'nominal',
source: 'OpenSky Network',
lastUpdate: 42,
});
});
test('empty roster envelope does not pretend to be nominal', () => {
const envelope = feedProvenanceEnvelope([]);
assert.equal(envelope.overall, null);
assert.match(envelope.note, /Do not invent/);
});
test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeSec(null, NOW), null);
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
+208 -4
View File
@@ -168,7 +168,14 @@ function stringOption(key, token, defaultValue) {
});
}
function enumOption(key, token, defaultValue, values, codes) {
function enumOption(
key,
token,
defaultValue,
values,
codes,
{ absentValue = defaultValue } = {},
) {
const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
@@ -176,6 +183,7 @@ function enumOption(key, token, defaultValue, values, codes) {
key,
token,
defaultValue,
absentValue,
normalize: (value) => normalizeEnum(values, value),
encode: (value) => codes[value],
decode: (value) => reverse[value] || null,
@@ -204,7 +212,131 @@ function integerOption(key, token, defaultValue) {
});
}
/**
* A share-link-only option: the whole-state codec carries it, but the stored
* local blob always holds its default.
*/
function shareOnlyOption(spec) {
return Object.freeze({ ...spec, shareOnly: true });
}
/**
* A signed integer in `[min, max]`, rejected (never clamped) outside it: a
* clamped box edge would be a different box, not a shorter spelling.
*/
function boundedIntegerOption(key, token, defaultValue, { min, max }) {
const parse = (value) => {
const number =
typeof value === 'number'
? value
: /^-?\d{1,9}$/.test(String(value).trim())
? Number(String(value).trim())
: NaN;
return Number.isInteger(number) && number >= min && number <= max
? number
: null;
};
return Object.freeze({
key,
token,
defaultValue,
normalize: parse,
encode: (value) => String(value),
decode: parse,
});
}
/**
* Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
* The calendar is checked both ways, and "today" is never consulted, so a
* link decodes the same whenever it is opened.
*/
const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
function imageryKey(product, year, month, day) {
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
const iso = Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: '';
return product && iso === `${year}-${month}-${day}`
? `${product}:${iso}`
: null;
}
function imageryPinOption(key, token) {
return Object.freeze({
key,
token,
defaultValue: null,
normalize: (value) => {
const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
typeof value === 'string' ? value.trim() : '',
);
return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
},
encode: (value) => {
const [product, day] = value.split(':');
return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
},
decode: (value) => {
const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
typeof value === 'string' ? value : '',
);
return match
? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
: null;
},
});
}
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-radar': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
enumOption(
'product',
'p',
'clouds-regional',
['clouds', 'clouds-regional'],
{ clouds: 'g', 'clouds-regional': 'r' },
),
]),
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
enumOption(
'overlay',
'o',
'none',
['none', 'speed', 'temperature', 'pressure'],
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
{ absentValue: 'speed' },
),
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
'km/h': 'k',
'm/s': 'm',
mph: 'i',
}),
booleanOption('paused', 'p', false),
]),
flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
@@ -249,6 +381,23 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
'recent-imagery': Object.freeze([
// Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
imageryPinOption('a', 'a'),
imageryPinOption('b', 'b'),
// 0 one image, 1 image against the basemap, 2 two images A / B.
boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
// The swipe position is one comparison's framing, not a preference: a
// stored split resurfaced in the next session's first comparison.
shareOnlyOption(
boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
),
booleanOption('viirs', 'v', false),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -331,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',
@@ -366,6 +520,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options',
optionOwner: 'radio',
}),
Object.freeze({
id: 'recent-imagery',
token: '1',
disposition: 'enabled+options',
optionOwner: 'recent-imagery',
}),
Object.freeze({
id: 'rocket-launches',
token: 'x',
@@ -384,6 +544,35 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
}),
Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }),
Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }),
Object.freeze({
id: 'weather-cyclones',
token: 'y',
disposition: 'enabled-only',
}),
Object.freeze({
id: 'weather-lightning',
token: 'l',
disposition: 'enabled+options',
optionOwner: 'weather-lightning',
}),
Object.freeze({
id: 'weather-radar',
token: 'v',
disposition: 'enabled+options',
optionOwner: 'weather-radar',
}),
Object.freeze({
id: 'weather-satellite',
token: 'o',
disposition: 'enabled+options',
optionOwner: 'weather-satellite',
}),
Object.freeze({
id: 'wind',
token: 'k',
disposition: 'enabled+options',
optionOwner: 'wind',
}),
]);
export const REGISTERED_LAYER_IDS = Object.freeze(
@@ -616,13 +805,28 @@ export function decodeLayerStateParams(params) {
return normalizeLayerState({ enabledLayerIds, options: rawOptions });
}
/** Stable local-storage representation (full IDs for debuggability). */
/** Options with share-link-only values reset to their defaults. */
function withoutShareOnlyOptions(options) {
const out = {};
for (const ownerId of OPTION_OWNER_IDS) {
out[ownerId] = { ...(options?.[ownerId] || {}) };
for (const spec of optionSpecs(ownerId)) {
if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue;
}
}
return out;
}
/**
* Stable local-storage representation (full IDs for debuggability), with
* share-link-only options at their defaults.
*/
export function serializeStoredLayerState(state) {
const normalized = normalizeLayerState(state);
return JSON.stringify({
v: LAYER_STATE_VERSION,
l: normalized.enabledLayerIds,
o: normalized.options,
o: withoutShareOnlyOptions(normalized.options),
});
}
@@ -634,7 +838,7 @@ export function parseStoredLayerState(raw) {
return null;
return normalizeLayerState({
enabledLayerIds: parsed.l,
options: parsed.o,
options: withoutShareOnlyOptions(parsed.o),
});
} catch {
return null;
+1095 -274
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -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 {
+222
View File
@@ -0,0 +1,222 @@
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
const DC_RAW = {
id: '1176042553',
lat: -52.942,
lon: -70.85,
properties: {
tags: {
name: 'AWS',
operator: 'Amazon Web Services',
'operator:short': 'AWS',
'capacity:it_load': '27 MW',
},
},
};
const DAM_RAW = {
id: '-19685440',
lat: -25.41,
lon: -54.59,
properties: {
name: 'Usina Hidrelétrica de Itaipu',
output: '14000 MW',
tags: {
name: 'Usina Hidrelétrica de Itaipu',
'name:en': 'Itaipu Dam',
operator: 'Itaipú Binacional',
associated_river: 'Paraná',
'plant:output:electricity': '14000 MW',
},
},
};
test('infra analyst record: datacenter maps name, operator, capacity', () => {
const r = mapAnalystRecord(DC_RAW, 'local-datacenters');
assert.deepEqual(r, {
id: 'AWS',
name: 'AWS',
lat: -52.942,
lon: -70.85,
operator: 'Amazon Web Services',
capacity: '27 MW',
river: null,
output: null,
});
});
test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => {
const r = mapAnalystRecord(DAM_RAW, 'local-dams');
assert.equal(r.id, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.name, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.operator, 'Itaipú Binacional');
assert.equal(r.river, 'Paraná');
assert.equal(r.output, '14000 MW');
assert.equal(r.capacity, null);
});
test('infra analyst record: unnamed feature falls back to source id', () => {
const r = mapAnalystRecord(
{
id: 'dc-42',
lat: 30.2,
lon: -97.7,
properties: { tags: { operator: 'Example Cloud' } },
},
'local-datacenters',
);
assert.equal(r.id, 'dc-42');
assert.equal(r.name, null);
assert.equal(r.operator, 'Example Cloud');
});
test('infra analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined, 'local-dams');
assert.equal(r.id, 'Dam');
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord(
{
...DAM_RAW,
entity: {},
position: { x: 1 },
},
'local-dams',
);
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('entity' in r, false);
assert.equal('position' in r, false);
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
}
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
type: 'Feature',
id: 'runtime-dam',
properties: {
name: 'Runtime Dam',
output: '12 MW',
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.7, 30.2],
[-97.69, 30.2],
[-97.69, 30.21],
[-97.7, 30.2],
],
],
},
}),
});
const viewer = {
selectedEntity: undefined,
dataSources: {
add(dataSource) {
return dataSource;
},
remove() {
return true;
},
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
moveEnd: new MockLayerEvent(),
flyTo() {},
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender: new MockLayerEvent(),
sampleHeightSupported: false,
sampleHeight() {
return undefined;
},
screenSpaceCameraController: { enableInputs: true },
pick() {
return null;
},
requestRender() {},
},
};
const layer = createLocalGeoJsonLayer({
id: 'local-dams',
url: '/runtime-dam.geojsonl',
name: 'Runtime Dams',
color: '#0088ff',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
projectToWindow: () => ({ x: 400, y: 300 }),
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
try {
assert.deepEqual(
layer.getAnalystRecords(),
[],
'unenabled layer has no analyst records',
);
await layer.enable(viewer);
const rows = layer.getAnalystRecords();
assert.equal(rows.length, 1);
assert.equal(rows[0].id, 'Runtime Dam');
assert.equal(rows[0].name, 'Runtime Dam');
assert.equal(rows[0].operator, 'Test Hydro');
assert.equal(rows[0].river, 'Test River');
assert.equal(rows[0].output, '12 MW');
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
assert.equal(
layer.getAnalystRecords(0).length,
1,
'non-finite/zero cap still returns at least one',
);
layer.disable(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'disable keeps stems but analyst snapshot is empty',
);
} finally {
layer.destroy(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'destroy releases analyst records',
);
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
+107 -1
View File
@@ -89,6 +89,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -310,7 +382,7 @@ export function localDatasetError(error) {
* standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers.
*/
@@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer(
computed: _lodComputed,
}),
/**
* Snapshot in-memory infrastructure features as plain JSON-safe objects
* for the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty. Disable keeps the loaded
* stems for reuse; this method still returns [] until the next enable.
* @param {number} [maxCount=2000] Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_stemRecords.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (let i = 0; i < _stemRecords.length; i++) {
if (result.length >= limit) break;
const record = _stemRecords[i];
const carto = record.carto;
result.push(
mapAnalystRecord(
{
id: record.id,
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
properties: propertyObject(record.entity),
},
id,
),
);
}
return result;
},
enable: async (viewer) => {
if (_destroyed) return;
_enabled = true;
+459
View File
@@ -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',
);
});
+150
View File
@@ -2746,6 +2746,12 @@ function makeControlElement() {
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
@@ -3204,6 +3210,7 @@ test('an async layer pushes its own row refresh, and a busy chip refuses clicks'
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
@@ -3336,3 +3343,146 @@ test('row action chips use live disabled state and descriptive counts without wr
else globalThis.document = originalDocument;
}
});
for (const scheduler of ['animation frame', 'timeout']) {
test(`row notifications coalesce with ${scheduler} and stop after destroy`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const frames = new Map();
let nextFrame = 0;
const originalRaf = globalThis.requestAnimationFrame;
const originalCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = scheduler === 'animation frame'
? (callback) => { frames.set(++nextFrame, callback); return nextFrame; }
: undefined;
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let notify;
layer.module.getRowControls = () => ({ chips: [] });
layer.module.setRowControlsListener = (listener) => { if (listener) notify = listener; };
manager.register(layer.module);
try {
manager.buildTogglePanel(makeControlElement());
const panel = manager._layerPanel;
let refreshes = 0;
const refresh = panel._refreshTogglePanel.bind(panel);
panel._refreshTogglePanel = () => { refreshes++; refresh(); };
for (let i = 0; i < 10; i++) notify();
assert.equal(refreshes, 0, 'notifications defer refresh');
if (scheduler === 'animation frame') assert.equal(frames.size, 1);
panel._flushRowControlsRefresh();
assert.equal(refreshes, 1);
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(frames.size, 0, 'flush cancels the pending frame');
assert.equal(refreshes, 1, 'flush does not leave duplicate work');
notify();
notify();
if (scheduler === 'animation frame') {
const [id, callback] = frames.entries().next().value;
frames.delete(id);
callback();
} else t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'scheduled callback refreshes once');
notify();
panel.destroy();
assert.equal(frames.size, 0, 'destroy cancels pending work');
notify();
panel._flushRowControlsRefresh();
t.mock.timers.tick(0);
assert.equal(refreshes, 2, 'retained notifications after destroy are inert');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
if (originalRaf === undefined) delete globalThis.requestAnimationFrame;
else globalThis.requestAnimationFrame = originalRaf;
if (originalCancel === undefined) delete globalThis.cancelAnimationFrame;
else globalThis.cancelAnimationFrame = originalCancel;
}
});
}
test('row info and legends reconcile in place while chips retain focus and order', async () => {
const originalDocument = globalThis.document;
globalThis.document = { createElement: makeControlElement, activeElement: null };
const manager = new DataLayerManager({});
const layer = makeSlowLayer('wind', { updateInterval: -1 });
let controls = {
chips: [{ id: 'one', label: 'One' }],
legend: [{ label: 'Low', color: '#0000ff', count: 1, blurb: 'Low values' }],
info: 'Forecast',
infoTitle: 'Source time',
};
layer.module.getRowControls = () => controls;
manager.register(layer.module);
try {
const container = makeControlElement();
manager.buildTogglePanel(container);
await manager.setEnabled('wind', true);
const row = container.querySelector('.data-toggle-controls');
const chip = row.children[0];
let legend = row.children[1];
const info = row.children[2];
chip.focus();
let writes = 0;
for (const key of ['textContent', 'title']) {
let value = info[key];
Object.defineProperty(info, key, {
get: () => value,
set: (next) => { writes++; value = next; },
});
}
controls = structuredClone(controls);
manager._refreshTogglePanel();
assert.equal(row.children[1], legend);
assert.equal(row.children[2], info);
assert.equal(writes, 0);
for (const change of [{ label: 'High' }, { color: '#ff0000' }, { count: 2 }, { blurb: 'High values' }]) {
Object.assign(controls.legend[0], change);
manager._refreshTogglePanel();
assert.notEqual(row.children[1], legend);
legend = row.children[1];
assert.equal(row.children[2], info);
assert.equal(globalThis.document.activeElement, chip);
}
controls.info = '<img onerror=alert(1)>';
controls.infoTitle = '';
controls.chips.push({ id: 'two', label: 'Two' });
manager._refreshTogglePanel();
assert.deepEqual(row.children.map(node => node.className), [
'data-toggle-chip chip-idle', 'data-toggle-chip chip-idle',
'data-toggle-legend-item', 'data-toggle-controls-info',
]);
assert.equal(row.children[2], legend);
assert.equal(row.children[3], info);
assert.equal(info.textContent, controls.info);
assert.equal(info.title, '');
assert.equal(info.children.length, 0, 'info remains plain text');
controls = { chips: [], legend: [] };
manager._refreshTogglePanel();
assert.equal(row.hidden, true);
assert.equal(info.hidden, true);
assert.deepEqual(row.children, [info]);
controls = { info: 'Restored', legend: [{ label: 'New', color: '#ffffff' }] };
manager._refreshTogglePanel();
assert.equal(row.children.at(-1), info);
assert.equal(info.hidden, false);
assert.equal(info.textContent, 'Restored');
} finally {
await manager.destroyAll();
if (originalDocument === undefined) delete globalThis.document;
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']);
});
+45
View File
@@ -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;
}
+48
View File
@@ -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,
);
});
+22 -19
View File
@@ -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'),
+7 -5
View File
@@ -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);
+4
View File
@@ -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; }
}
+458
View File
@@ -0,0 +1,458 @@
// src/data/satellitePass.js
/**
* Satellite pass prediction with naked-eye visibility.
* Look angles come from SGP4; rise/set are bisected to sub-second precision,
* the peak is refined with a parabola, and visibility uses the USNO
* low-precision Sun position with a cylindrical Earth shadow. Scan pattern from
* skylight (MIT) shared/src/celestial.ts nextISSPass.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
const EARTH_RADIUS_KM = 6371.0;
const AU_KM = 149597870.7;
/**
* Observer look angles at an instant, or null when propagation fails.
* @param {Object} satrec SGP4 satellite record
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @param {number} latDeg Observer latitude in degrees [-90, 90]
* @param {number} lonDeg Observer longitude in degrees [-180, 180]
* @returns {{ elevDeg: number, azDeg: number, satECI: {x:number, y:number, z:number} } | null}
*/
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
let pv;
try {
pv = propagate(satrec, date);
} catch {
return null;
}
const pos = pv && pv.position;
if (
!pos ||
typeof pos === 'boolean' ||
![pos.x, pos.y, pos.z].every(Number.isFinite)
)
return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
satECI: pos,
};
}
/**
* Compute Sun position in geocentric ECI coordinates using USNO low-precision solar coordinates.
* Accurate to within ~1 arcminute (~0.017°) within centuries of J2000.0.
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {{ x: number, y: number, z: number, raDeg: number, decDeg: number }}
*/
export function solarPositionECI(dateMs) {
const JD = dateMs / 86400000 + 2440587.5;
const D = JD - 2451545.0; // days since J2000.0
const g = ((((357.529 + 0.98560028 * D) % 360) + 360) % 360) * D2R;
const q = (((280.459 + 0.98564736 * D) % 360) + 360) % 360;
const L =
((((q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) % 360) + 360) % 360) *
D2R;
const e = (23.439 - 0.00000036 * D) * D2R;
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
return {
x: AU_KM * Math.cos(dec) * Math.cos(ra),
y: AU_KM * Math.cos(dec) * Math.sin(ra),
z: AU_KM * Math.sin(dec),
raDeg: (((ra * R2D) % 360) + 360) % 360,
decDeg: dec * R2D,
};
}
/**
* Check whether a satellite in ECI is illuminated by the Sun (outside Earth shadow).
* Uses a cylindrical Earth shadow model with mean radius 6,371 km.
* @param {{ x: number, y: number, z: number }} satECI Satellite position in km
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {boolean} True if sunlit, false if in eclipse (Earth shadow)
*/
export function isSatelliteSunlit(satECI, dateMs) {
if (!satECI || typeof satECI === 'boolean') return false;
const sun = solarPositionECI(dateMs);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
const proj = satECI.x * sx + satECI.y * sy + satECI.z * sz;
if (proj > 0) return true; // Sunward side of Earth
const dx = satECI.x - proj * sx;
const dy = satECI.y - proj * sy;
const dz = satECI.z - proj * sz;
return Math.hypot(dx, dy, dz) > EARTH_RADIUS_KM;
}
/**
* Compute the observer local solar elevation angle in degrees.
* @param {number} latDeg Observer latitude in degrees
* @param {number} lonDeg Observer longitude in degrees
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {number} Solar elevation in degrees [-90, 90]
*/
export function observerSolarElevation(latDeg, lonDeg, dateMs) {
const sun = solarPositionECI(dateMs);
const gmst = gstime(new Date(dateMs));
const gmstDeg = (((gmst * R2D) % 360) + 360) % 360;
const localHourAngleDeg =
(((gmstDeg + lonDeg - sun.raDeg) % 360) + 360) % 360;
const H = localHourAngleDeg * D2R;
const phi = latDeg * D2R;
const dec = sun.decDeg * D2R;
const sinElev =
Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H);
return Math.asin(Math.max(-1, Math.min(1, sinElev))) * R2D;
}
/**
* Check whether the observer sky is dark (civil, nautical, or astronomical twilight / night).
* Default threshold is -6° (civil twilight).
* @param {number} latDeg Observer latitude
* @param {number} lonDeg Observer longitude
* @param {number} dateMs UTC timestamp
* @param {number} [maxSunElevDeg=-6] Sun elevation ceiling (default -6° for civil twilight)
* @returns {boolean} True if sun is at or below the threshold
*/
export function isObserverDark(latDeg, lonDeg, dateMs, maxSunElevDeg = -6) {
return observerSolarElevation(latDeg, lonDeg, dateMs) <= maxSunElevDeg;
}
/**
* Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0.
* Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s.
* @private
*/
function _bisectBoundary(
satrec,
latDeg,
lonDeg,
tLow,
tHigh,
minElevDeg,
isRising,
steps = 10,
) {
let low = tLow;
let high = tHigh;
for (let i = 0; i < steps; i++) {
const mid = (low + high) / 2;
const look = lookAnglesAt(satrec, mid, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (isRising) {
if (elev >= minElevDeg) {
high = mid;
} else {
low = mid;
}
} else {
if (elev >= minElevDeg) {
low = mid;
} else {
high = mid;
}
}
}
return isRising ? Math.ceil(high) : Math.floor(low);
}
/**
* Find the next pass of a satellite over an observer location.
* Uses 20s coarse scanning, bisection for sub-second rise/set times,
* parabolic interpolation for the peak, and optional naked-eye visibility filtering.
*
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude [-90, 90]
* @param {number} options.lonDeg Observer longitude [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°)
* @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h)
* @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s)
* @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s)
* @param {boolean} [options.requireVisible=false] Skip passes that are not naked-eye visible
* @param {number} [options.maxSunElevDeg=-6] Ceiling for observer darkness (default -6° for civil twilight)
* @returns {{
* riseMs: number,
* setMs: number,
* maxElevDeg: number,
* maxElevMs: number,
* riseAzDeg: number,
* visible: boolean,
* sunlit: boolean,
* observerDark: boolean
* } | null} `visible` is true when any sampled part of the pass is sunlit under a
* dark sky; `sunlit` and `observerDark` describe the peak only.
*/
export function findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 20,
fineStepSec = 5,
requireVisible = false,
maxSunElevDeg = -6,
}) {
if (
!satrec ||
![
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
maxSunElevDeg,
].every(Number.isFinite) ||
Math.abs(latDeg) > 90 ||
Math.abs(lonDeg) > 180 ||
Math.abs(fromMs) > 8.63e15 ||
minElevDeg < 0 ||
minElevDeg > 90 ||
horizonHours <= 0 ||
horizonHours > 72 ||
coarseStepSec < 1 ||
coarseStepSec > 120 ||
fineStepSec < 0.1 ||
fineStepSec > 60
)
return null;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarseMs = coarseStepSec * 1000;
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
let searchCursor = fromMs;
while (searchCursor <= horizonMs) {
let tPrev = searchCursor;
let tHit = null;
let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90;
// Check if we start already inside a pass
if (elevPrev >= minElevDeg) {
tHit = searchCursor;
} else {
// Coarse forward scan
for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) {
const look = lookAnglesAt(satrec, t, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (elev >= minElevDeg) {
tHit = t;
break;
}
tPrev = t;
elevPrev = elev;
}
}
if (tHit == null) return null; // No pass found in remaining horizon
// Resolve rise
let riseMs;
if (tHit === searchCursor && elevPrev >= minElevDeg) {
riseMs = searchCursor;
} else {
riseMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tPrev,
tHit,
minElevDeg,
true,
);
}
// Transit the pass to track peak and bracket set between tStepPrev and tCurr
let tCurr = riseMs;
let maxElevDeg = -90;
let maxElevMs = riseMs;
let tStepPrev = riseMs;
let setMs = null;
let hasVisibleSegment = false;
let elevPrevSample = null;
let peakLeftElev = null;
let peakRightElev = null;
let peakStepMs = fineMs;
// Visibility at an instant inside [riseMs, setMs], so no elevation gate
const checkVisibility = (tMs) => {
if (hasVisibleSegment) return;
const look = lookAnglesAt(satrec, tMs, latDeg, lonDeg);
if (!look) return;
const sunlit = isSatelliteSunlit(look.satECI, tMs);
const dark = isObserverDark(latDeg, lonDeg, tMs, maxSunElevDeg);
if (sunlit && dark) {
hasVisibleSegment = true;
}
};
// Check visibility at rise
checkVisibility(riseMs);
// Transit forward in fine steps
while (tCurr <= horizonMs) {
const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (look) {
const sunlit = isSatelliteSunlit(look.satECI, tCurr);
const dark = isObserverDark(latDeg, lonDeg, tCurr, maxSunElevDeg);
if (sunlit && dark && elev >= minElevDeg) {
hasVisibleSegment = true;
}
}
if (elev < minElevDeg && tCurr > riseMs) {
if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
// Below threshold: bisect set between tStepPrev and tCurr
setMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tStepPrev,
tCurr,
minElevDeg,
false,
);
break;
}
if (elev > maxElevDeg) {
maxElevDeg = elev;
maxElevMs = tCurr;
peakLeftElev = elevPrevSample;
peakRightElev = null;
} else if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
elevPrevSample = elev;
tStepPrev = tCurr;
tCurr += fineMs;
}
if (setMs == null) {
setMs = Math.min(horizonMs, tCurr);
}
// Refine peak culmination via 3-point parabolic interpolation around maxElevMs
// using adjacent transit samples (zero additional SGP4 calls)
let refinedPeakMs = maxElevMs;
let refinedPeakElevDeg = maxElevDeg;
if (
Number.isFinite(peakLeftElev) &&
Number.isFinite(peakRightElev) &&
peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down
) {
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
if (Math.abs(shift) < peakStepMs) {
refinedPeakMs = Math.max(
riseMs,
Math.min(setMs, Math.round(maxElevMs + shift)),
);
refinedPeakElevDeg = Math.min(
90,
maxElevDeg -
Math.pow(peakLeftElev - peakRightElev, 2) /
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)),
);
}
}
// Sun and sky state at the peak
const peakLook = lookAnglesAt(satrec, refinedPeakMs, latDeg, lonDeg);
const peakSunlit = peakLook
? isSatelliteSunlit(peakLook.satECI, refinedPeakMs)
: false;
const peakDark = isObserverDark(
latDeg,
lonDeg,
refinedPeakMs,
maxSunElevDeg,
);
// Check visibility at the refined peak and at set
checkVisibility(refinedPeakMs);
checkVisibility(setMs);
if (!hasVisibleSegment) {
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
const look = lookAnglesAt(satrec, time, latDeg, lonDeg);
return [
Boolean(look && isSatelliteSunlit(look.satECI, time)),
isObserverDark(latDeg, lonDeg, time, maxSunElevDeg),
];
});
}
// Skip passes with no visible part when requireVisible is set
if (requireVisible && !hasVisibleSegment) {
searchCursor = Math.max(setMs + 1000, searchCursor + coarseMs);
continue;
}
const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg: refinedPeakElevDeg,
maxElevMs: refinedPeakMs,
riseAzDeg: riseLook ? riseLook.azDeg : 0,
visible: hasVisibleSegment,
sunlit: peakSunlit,
observerDark: peakDark,
};
}
return null;
}
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
export function hasVisibleInterval(start, end, conditionsAt) {
const visible = (states) => states[0] && states[1];
for (let left = start; left < end; left += 5000) {
const right = Math.min(end, left + 5000);
const a = conditionsAt(left);
const b = conditionsAt(right);
if (visible(a) || visible(b)) return true;
const cuts = [left, right];
for (let condition = 0; condition < 2; condition++) {
if (a[condition] === b[condition]) continue;
let low = left;
let high = right;
while (high - low > 20) {
const mid = (low + high) / 2;
if (conditionsAt(mid)[condition] === a[condition]) low = mid;
else high = mid;
}
cuts.push((low + high) / 2);
}
cuts.sort((a, b) => a - b);
for (let i = 1; i < cuts.length; i++) {
if (visible(conditionsAt((cuts[i - 1] + cuts[i]) / 2))) return true;
}
}
return false;
}
+282
View File
@@ -0,0 +1,282 @@
// src/data/satellitePass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec, propagate } from 'satellite.js';
import {
findNextSatellitePass,
hasVisibleInterval,
lookAnglesAt,
solarPositionECI,
isSatelliteSunlit,
observerSolarElevation,
isObserverDark,
} from './satellitePass.js';
// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC).
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
test('solarPositionECI matches the Sun at J2000.0', () => {
// Reference: Cesium Simon1994PlanetaryPositions at 2000-01-01 12:00 UTC
// gives RA 281.289°, Dec -23.033°.
const j2000 = solarPositionECI(Date.UTC(2000, 0, 1, 12, 0, 0));
assert.ok(
Math.abs(j2000.raDeg - 281.289) < 0.05,
`RA ${j2000.raDeg}° within 0.05°`,
);
assert.ok(
Math.abs(j2000.decDeg - -23.033) < 0.05,
`Dec ${j2000.decDeg}° within 0.05°`,
);
});
test('solarPositionECI calculates reasonable solar coordinates across seasons', () => {
// Near Autumnal Equinox (Sept 22, 2020): declination ≈ 0°
const equinox = solarPositionECI(Date.UTC(2020, 8, 22, 12, 0, 0));
assert.ok(
Math.abs(equinox.decDeg) < 1.0,
'declination near equinox must be close to 0°',
);
// Near Summer Solstice (June 21, 2020): declination ≈ +23.44°
const solsticeSummer = solarPositionECI(Date.UTC(2020, 5, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeSummer.decDeg - 23.44) < 0.5,
'summer declination ≈ 23.44°',
);
// Near Winter Solstice (Dec 21, 2020): declination ≈ -23.44°
const solsticeWinter = solarPositionECI(Date.UTC(2020, 11, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeWinter.decDeg - -23.44) < 0.5,
'winter declination ≈ -23.44°',
);
});
test('observerSolarElevation distinguishes local noon from local midnight', () => {
// Greenwich noon on equinox: solar elevation should be close to 90 - lat
const greenwichNoon = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 12, 0, 0),
);
assert.ok(greenwichNoon > 30, 'sun must be well above horizon at local noon');
// Greenwich midnight: solar elevation must be deeply negative
const greenwichMidnight = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 0, 0, 0),
);
assert.ok(
greenwichMidnight < -20,
'sun must be well below horizon at midnight',
);
assert.ok(isObserverDark(51.5, 0, Date.UTC(2020, 8, 22, 0, 0, 0), -6));
});
test('isSatelliteSunlit accurately computes cylindrical umbra shadow', () => {
const summerDate = Date.UTC(2020, 5, 21, 12, 0, 0);
const sun = solarPositionECI(summerDate);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
// Point toward the Sun: must be sunlit
const daysideSat = { x: 7000 * sx, y: 7000 * sy, z: 7000 * sz };
assert.equal(isSatelliteSunlit(daysideSat, summerDate), true);
// Point directly in Earth shadow (opposite Sun, r = 6700 km, < Earth radius offset): must be eclipsed
const shadowedSat = { x: -6700 * sx, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(shadowedSat, summerDate), false);
// Point on night side but high altitude / far perpendicular to shadow axis (> 6371 km): must be sunlit
const highAltSat = { x: -6700 * sx + 10000, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(highAltSat, summerDate), true);
});
test('isSatelliteSunlit accurately identifies SGP4 orbital shadow entry and exit', () => {
const satrec = twoline2satrec(L1, L2);
// Minute 17 after FROM_MS (1221914820000): ISS is inside Earth umbra shadow cone
const tShadowed = FROM_MS + 17 * 60_000;
const pvShadowed = propagate(satrec, new Date(tShadowed));
assert.equal(
isSatelliteSunlit(pvShadowed.position, tShadowed),
false,
'ISS must be eclipsed in Earth umbra during orbital night',
);
// Minute 18 after FROM_MS (1221914880000): ISS emerges from umbra into sunlight
const tSunlit = FROM_MS + 18 * 60_000;
const pvSunlit = propagate(satrec, new Date(tSunlit));
assert.equal(
isSatelliteSunlit(pvSunlit.position, tSunlit),
true,
'ISS must be illuminated upon orbital sunrise',
);
});
test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
});
assert.ok(pass, 'expected a pass within 24h at Austin for ISS');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Verify bisection accuracy at rise time:
const beforeRise = lookAnglesAt(
satrec,
pass.riseMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atRise = lookAnglesAt(
satrec,
pass.riseMs,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const afterRise = lookAnglesAt(
satrec,
pass.riseMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeRise && beforeRise.elevDeg < 10.05,
'elevation before riseMs must be below threshold',
);
assert.ok(
atRise && Math.abs(atRise.elevDeg - 10) < 0.2,
'elevation at riseMs must be within 0.2° of 10°',
);
assert.ok(
afterRise && afterRise.elevDeg > 9.95,
'elevation after riseMs must be rising above threshold',
);
// Verify bisection accuracy at set time:
const beforeSet = lookAnglesAt(
satrec,
pass.setMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg);
const afterSet = lookAnglesAt(
satrec,
pass.setMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeSet && beforeSet.elevDeg > 9.95,
'elevation before setMs must be above threshold',
);
assert.ok(
atSet && Math.abs(atSet.elevDeg - 10) < 0.2,
'elevation at setMs must be within 0.2° of 10°',
);
assert.ok(
afterSet && afterSet.elevDeg < 10.05,
'elevation after setMs must be below threshold',
);
// Parabolic culmination accuracy: max elevation must be peak
assert.ok(pass.maxElevDeg >= atRise.elevDeg);
assert.ok(pass.maxElevDeg >= atSet.elevDeg);
});
test('findNextSatellitePass skips non-visible passes when requireVisible is true', () => {
const satrec = twoline2satrec(L1, L2);
const geometricPass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
requireVisible: false,
});
assert.ok(geometricPass);
const visiblePass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
horizonHours: 72,
requireVisible: true,
});
assert.ok(visiblePass, 'expected at least one visible pass within horizon');
assert.equal(visiblePass.visible, true);
assert.ok(visiblePass.riseMs >= geometricPass.riseMs);
});
test('findNextSatellitePass samples the peak at fineStepSec', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 };
const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 });
const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 });
assert.ok(fine && coarse);
// The step is used: peak samples differ.
assert.notEqual(fine.maxElevMs, coarse.maxElevMs);
// Rise comes from bisection, so it does not depend on the step.
assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000);
// Parabolic refinement keeps 10s sampling close to the 1s peak.
assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000);
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
});
test('invalid numerical search options return promptly without unbounded scans', () => {
const satrec = twoline2satrec(L1, L2);
const options = { satrec, ...AUSTIN, fromMs: FROM_MS };
for (const change of [
{ coarseStepSec: 0 },
{ coarseStepSec: -1 },
{ horizonHours: Infinity },
{ horizonHours: 73 },
{ fineStepSec: NaN },
{ latDeg: 91 },
{ fromMs: NaN },
]) {
assert.equal(findNextSatellitePass({ ...options, ...change }), null);
}
});
test('visibility catches a short overlap between shadow exit and dawn', () => {
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]),
true,
);
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]),
false,
);
});
test('a pass already underway clips rise to the requested start', () => {
const satrec = twoline2satrec(L1, L2);
const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS });
const clipped = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: first.maxElevMs,
});
assert.equal(clipped.riseMs, first.maxElevMs);
assert.ok(clipped.setMs >= clipped.riseMs);
assert.ok(
clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs,
);
});
+154
View File
@@ -0,0 +1,154 @@
// Focused tests for the satellite analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import satellitesLayer, {
mapAnalystRecord,
_clearDenseCatalogStateForTest,
_setTrackedSatelliteRefreshStateForTest,
} from './satellites.js';
const ISS_NORAD = 25544;
const ISS_L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const ISS_L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const FULL_RAW = {
noradId: 25544,
name: 'ISS (ZARYA)',
group: 'stations',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
};
test('satellite analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RAW);
assert.deepEqual(r, {
id: 'ISS (ZARYA)',
noradId: '25544',
name: 'ISS (ZARYA)',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
satelliteClass: 'STATION · ISS',
group: 'stations',
});
});
test('satellite analyst record: nameless row falls back to SAT-norad id', () => {
const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 });
assert.equal(r.id, 'SAT-25544');
assert.equal(r.name, null);
});
test('satellite analyst record: ISS class wins even when ingested as visual', () => {
const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' });
assert.equal(r.satelliteClass, 'STATION · ISS');
});
test('satellite analyst record: GPS group maps to NAV · GPS', () => {
const r = mapAnalystRecord({
noradId: 24876,
name: 'GPS BIIR-2 (PRN 13)',
group: 'gps-ops',
lat: 0,
lon: 0,
altitudeM: 20_200_000,
speedMps: 3870,
});
assert.equal(r.satelliteClass, 'NAV · GPS');
assert.equal(r.group, 'gps-ops');
});
test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined);
assert.equal(r.id, 'SAT-00000');
assert.equal(r.satelliteClass, null);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord({
...FULL_RAW,
position: { x: 1 },
satrec: { dummy: true },
});
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('position' in r, false);
assert.equal('satrec' in r, false);
});
test('satellite getAnalystRecords: disabled or empty catalog returns []', () => {
_clearDenseCatalogStateForTest();
assert.deepEqual(satellitesLayer.getAnalystRecords(), []);
});
test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => {
const satrec = twoline2satrec(ISS_L1, ISS_L2);
const point = {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000),
};
_setTrackedSatelliteRefreshStateForTest({
noradId: ISS_NORAD,
name: 'ISS (ZARYA)',
satrec,
entity: {},
point,
viewer: {},
now: () => Date.now(),
neighbours: [
{
noradId: 99999,
name: 'STARLINK-TEST',
satrec,
group: 'dense',
point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) },
},
{
noradId: 24876,
name: 'GPS BIIR-2',
satrec,
group: 'gps-ops',
point: {
position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000),
},
},
],
});
try {
const capped = satellitesLayer.getAnalystRecords(2);
assert.equal(capped.length, 2);
assert.deepEqual(
capped.map((r) => r.noradId),
['25544', '24876'],
);
assert.equal(
capped.some((r) => r.group === 'dense'),
false,
);
const all = satellitesLayer.getAnalystRecords(10);
assert.equal(all.length, 3);
assert.equal(all[2].noradId, '99999');
assert.equal(all[2].satelliteClass, 'COMMS · STARLINK');
for (const row of all) {
assert.ok(
Number.isFinite(row.lat) && Number.isFinite(row.lon),
`${row.id} needs a geodetic position`,
);
assert.deepEqual(JSON.parse(JSON.stringify(row)), row);
}
} finally {
_clearDenseCatalogStateForTest();
}
});
+3
View File
@@ -41,6 +41,7 @@ export const _clearSatelliteLabelLifecycleForTest =
export const applySatellitePointFocusDeemphasis =
layer.applySatellitePointFocusDeemphasis;
export const getNextIssPass = layer.getNextIssPass;
export const getNextSatellitePass = layer.getNextSatellitePass;
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
@@ -49,3 +50,5 @@ export {
ISS_OVERLAY_SOURCE_OPTIONS,
} from '../layers/satellites/index.js';
export default layer;
export { mapAnalystRecord } from '../layers/satellites/records.js';
+149
View File
@@ -0,0 +1,149 @@
/**
* @file Shared address contract for receiver taps.
*
* A "tap" is a layer fed by hardware the user owns and runs themselves. What
* this module governs is narrower than "tap" in general: it is for taps whose
* upstream address arrives **from the browser at request time**, which is what
* makes the route an SSRF surface. Today that is the Rayhunter device (#56),
* which is addressed as `?base=host:port`, and it would cover an RTL-SDR /
* dump1090 receiver (#57) if that one is built server-side.
*
* It deliberately does NOT cover the other layers in the receiver-taps lane,
* because they are a different shape and forcing them through this check would
* be wrong:
*
* - Meshtastic (#6) reads a fixed public broker (`mqtt.meshtastic.org`).
* - WiGLE (#11) reads a fixed public API behind a token.
* - TAK (#7) takes its host from operator-set env config, not from the
* browser, and a TAK server is frequently a legitimate REMOTE host — a
* private-only rule would break it.
*
* This module is the single place that constraint lives, so taps cannot each
* re-derive it and disagree. The rule is deliberately narrow:
*
* A tap may address the loopback interface or an RFC1918 private network,
* plus the `localhost` and `*.local` (mDNS) names. Nothing else.
*
* That is the whole point of a tap — it reads *your* device on *your* network.
* A tap pointed at a public address is either a mistake or someone using the
* proxy as a confused deputy, and both deserve the same answer.
*
* Two deliberate non-goals, so the next person does not think they were
* oversights:
*
* 1. No general hostname resolution. A name other than `localhost`/`*.local`
* is rejected outright rather than resolved-and-pinned. Local devices are
* reachable by IP or mDNS, so supporting arbitrary names would buy nothing
* and would drag in DNS-rebinding defence (resolve, pin, re-check on every
* redirect) that `server/providers/radio/transport.js` already shows is not
* small. If a tap ever genuinely needs it, pin it there rather than
* loosening the check here.
* 2. No IPv6. Link-local IPv6 carries a zone index (`%eth0`) whose handling is
* platform-specific, and no tap has needed it. Rejected explicitly rather
* than half-supported.
*
* Lives in `src/data/` rather than under `server/providers/` because both
* sides need the same answer: the server as a trust boundary, the browser to
* give immediate feedback on a typed address without a round trip. Sharing one
* module means they cannot drift — the same reason #274 exists about eight
* copies of haversine. `server/providers/gbfs.js` importing
* `src/data/gbfsSource.js` is the established direction for this.
*
* Note on reuse: `isNonGlobalIpv4()` in `server/providers/radio/stations.js` answers a
* neighbouring question and was deliberately NOT reused. It reports malformed
* input such as `999.1.1.1` as non-global, which is the safe direction for the
* radio proxy's "is this public enough to fetch?" test but inverts into
* *allowing* garbage under a tap's "is this local enough to fetch?" test. The
* positive allowlist below cannot invert that way.
*
* @module data/tapAddress
*/
/** Link-local (169.254/16) is excluded wholesale, which also covers the cloud
* metadata address at 169.254.169.254 by construction rather than by blocklist. */
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
/** Single mDNS label or dotted name ending in `.local`, e.g. `rayhunter.local`. */
const MDNS_RE = /^(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})*\.local$/;
const MAX_PORT = 65535;
/**
* Whether a dotted-quad string is a loopback or RFC1918 private address.
*
* Written as a positive allowlist: every octet must parse and be in range
* before any range test runs, so malformed input can never fall through to
* "allowed".
*
* @param {string} host
* @returns {boolean}
*/
export function isLocalIpv4(host) {
const match = IPV4_RE.exec(host);
if (!match) return false;
const [a, b] = [Number(match[1]), Number(match[2])];
const octets = match.slice(1).map(Number);
if (
octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
)
return false;
return (
a === 127 || // loopback
a === 10 || // 10/8
(a === 172 && b >= 16 && b <= 31) || // 172.16/12
(a === 192 && b === 168) // 192.168/16
);
}
/**
* Parse and validate a user-supplied `host:port` tap address.
*
* @param {unknown} raw e.g. `"192.168.1.1:8080"`, `"localhost:8080"`.
* @returns {{host: string, port: number, origin: string}|null} null when the
* address is malformed or not local. Callers must treat null as a 400 and
* must not fall back to a default host — a tap with no valid address has
* nothing to read.
*/
export function parseTapAddress(raw) {
const value = String(raw ?? '')
.trim()
.toLowerCase();
if (!value || value.length > 263) return null;
// Reject a scheme, credentials, path, or query outright rather than
// stripping them: each is a sign the caller meant a URL, not an address,
// and quietly repairing it is how a parser ends up disagreeing with the
// thing that later builds the request.
if (/[/\\?#@]/.test(value) || value.includes('://')) return null;
const colon = value.lastIndexOf(':');
if (colon <= 0 || colon === value.length - 1) return null;
const host = value.slice(0, colon);
const portText = value.slice(colon + 1);
if (!/^\d{1,5}$/.test(portText)) return null;
const port = Number(portText);
if (port < 1 || port > MAX_PORT) return null;
const hostOk =
host === 'localhost' || MDNS_RE.test(host) || isLocalIpv4(host);
if (!hostOk) return null;
return { host, port, origin: `http://${host}:${port}` };
}
/**
* Build an absolute upstream URL for a tap request.
*
* The path is supplied by the tap module, never by the client, so this only
* has to guarantee the two halves join predictably.
*
* @param {{origin: string}} address from {@link parseTapAddress}.
* @param {string} path absolute path beginning with `/`.
* @returns {string}
*/
export function tapUrl(address, path) {
if (!address?.origin)
throw new TypeError('tapUrl: address must come from parseTapAddress');
if (typeof path !== 'string' || !path.startsWith('/')) {
throw new TypeError('tapUrl: path must be an absolute path');
}
return `${address.origin}${path}`;
}
+147
View File
@@ -0,0 +1,147 @@
// src/data/tapAddress.test.mjs
// Contract tests for the shared receiver-tap address check. Pure — no server.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isLocalIpv4, parseTapAddress, tapUrl } from './tapAddress.js';
test('parseTapAddress: accepts loopback and every RFC1918 range', () => {
for (const address of [
'127.0.0.1:8080',
'10.0.0.5:1234',
'172.16.0.1:80',
'172.31.255.254:65535',
'192.168.1.1:8080',
'localhost:8080',
'rayhunter.local:8080',
'dump1090.lan.local:30003',
]) {
assert.ok(parseTapAddress(address), `${address} should be accepted`);
}
});
test('parseTapAddress: rejects public addresses — a tap reads your own network', () => {
for (const address of [
'8.8.8.8:80',
'1.1.1.1:443',
'93.184.216.34:80',
'example.com:80',
'api.wigle.net:443',
// 172.15 and 172.32 sit just outside 172.16/12.
'172.15.0.1:80',
'172.32.0.1:80',
// 192.167/192.169 bracket 192.168/16.
'192.167.1.1:80',
'192.169.1.1:80',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects link-local, so the cloud metadata address cannot be reached', () => {
assert.equal(parseTapAddress('169.254.169.254:80'), null);
assert.equal(parseTapAddress('169.254.1.1:80'), null);
});
test('parseTapAddress: malformed dotted-quads are rejected, not treated as private', () => {
// The trap this module exists to avoid: a classifier that reports garbage as
// "not public" would let these through under an is-it-local test.
for (const address of [
'999.1.1.1:80',
'10.0.0:80',
'10.0.0.1.5:80',
'10.0.0.256:80',
'-1.0.0.1:80',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects anything shaped like a URL rather than repairing it', () => {
for (const address of [
'http://127.0.0.1:8080',
'127.0.0.1:8080/api/status',
'127.0.0.1:8080?x=1',
'127.0.0.1:8080#f',
'[email protected]:8080',
'127.0.0.1:8080\\x',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects a missing, empty, or out-of-range port', () => {
for (const address of [
'127.0.0.1',
'127.0.0.1:',
'127.0.0.1:0',
'127.0.0.1:65536',
'127.0.0.1:abc',
':8080',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects IPv6 explicitly rather than half-supporting it', () => {
assert.equal(parseTapAddress('[::1]:8080'), null);
assert.equal(parseTapAddress('::1:8080'), null);
assert.equal(parseTapAddress('[fe80::1%eth0]:8080'), null);
});
test('parseTapAddress: handles non-string and absent input without throwing', () => {
for (const value of [null, undefined, 0, {}, [], NaN, true]) {
assert.equal(parseTapAddress(value), null);
}
});
test('parseTapAddress: normalizes case and surrounding whitespace', () => {
const parsed = parseTapAddress(' RayHunter.LOCAL:8080 ');
assert.deepEqual(parsed, {
host: 'rayhunter.local',
port: 8080,
origin: 'http://rayhunter.local:8080',
});
});
test('isLocalIpv4: boundaries of each allowed range', () => {
assert.ok(isLocalIpv4('127.255.255.255'));
assert.ok(isLocalIpv4('10.255.255.255'));
assert.ok(isLocalIpv4('172.16.0.0'));
assert.ok(isLocalIpv4('172.31.255.255'));
assert.ok(!isLocalIpv4('172.15.255.255'));
assert.ok(!isLocalIpv4('172.32.0.0'));
assert.ok(!isLocalIpv4('126.255.255.255'));
assert.ok(!isLocalIpv4('128.0.0.1'));
});
test('tapUrl: joins a parsed address with a module-supplied path', () => {
const address = parseTapAddress('192.168.1.1:8080');
assert.equal(
tapUrl(address, '/api/qmdl-manifest'),
'http://192.168.1.1:8080/api/qmdl-manifest',
);
});
test('tapUrl: refuses an unparsed address or a relative path', () => {
const address = parseTapAddress('192.168.1.1:8080');
assert.throws(() => tapUrl(null, '/x'), TypeError);
assert.throws(() => tapUrl({ origin: '' }, '/x'), TypeError);
assert.throws(() => tapUrl(address, 'api/status'), TypeError);
assert.throws(() => tapUrl(address, ''), TypeError);
});
+4 -2
View File
@@ -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`);
}
});
+1
View File
@@ -222,6 +222,7 @@ export function getActiveTrackedReadoutId() {
const READOUT_CONTEXT_LAYERS = new Set([
'military-installations',
'alpr-cameras',
'local-adsb',
]);
/**
+3 -2
View File
@@ -135,7 +135,7 @@ export function applyVesselOverlayPolicy(
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
const gapPx = Math.max(selected ? 28 : 12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
@@ -144,7 +144,8 @@ export function applyVesselOverlayPolicy(
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
// Selected detail cards may move beside the contact to clear solid panels.
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
+1
View File
@@ -75,6 +75,7 @@ test('vessel host policy uses always-on shared fade and protected selected lane'
id: 'vessel:2', position, title: 'SELECTED', gapPx: 12, selected: true,
});
assert.equal(selected.variant, 'selected');
assert.equal(selected.verticalOnly, false, 'selected detail can clear panels on either side');
assert.equal(selected.protected, true);
assert.equal(selected.collisionGroup, 'ambient-card');
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
createWindSource(),
['getSnapshot'],
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => {
const writes = []; let deleted = false;
const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) };
await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/);
assert.deepEqual(writes, [20,0]); assert.equal(deleted, true);
});
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gfsObjectKey,
selectLatestGfsCycle,
} from '../../server/providers/wind/catalog.js';
import {
parseGfsIdx,
windMessageRanges,
fetchRange,
fetchGfsWind,
loadOptionalScalar,
} from '../../server/providers/wind/gfs.js';
test('catalog and index helpers', async () => {
assert.equal(
gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }),
'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003',
);
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
const p = parseGfsIdx(
'1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z',
);
assert.deepEqual(windMessageRanges(p), {
u: { start: 10, end: 19 },
v: { start: 20, end: 29 },
});
const b = await fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
});
assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
});
test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => {
await assert.rejects(
fetchRange({ url: 'x', start: -1, end: 2 }),
/range too large/,
);
let cancelled = false;
const body = new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(10));
},
cancel() {
cancelled = true;
},
});
await assert.rejects(
fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () => new Response(body, { status: 206 }),
}),
/budget/,
);
assert.equal(cancelled, true);
});
const WEATHER_IDX = [
'1:0:d=2026091506:UGRD:10 m above ground:6 hour fcst:',
'2:2:d=2026091506:VGRD:10 m above ground:6 hour fcst:',
'3:4:d=2026091506:TMP:850 mb:6 hour fcst:',
'4:6:d=2026091506:TMP:2 m above ground:6 hour fcst:',
'5:8:d=2026091506:PRMSL:mean sea level:6 hour fcst:',
'6:10:d=2026091506:HGT:surface:6 hour fcst:',
].join('\n');
test('GFS overlays select exact levels from the same issue and forecast object', async () => {
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'temperature' })
.scalar,
{ start: 6, end: 7 },
);
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'pressure' }).scalar,
{ start: 8, end: 9 },
);
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchGfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.idx')) return new Response(WEATHER_IDX);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 6 ? 293.15 : bytes[0] === 8 ? 101325 : bytes[0],
),
units: bytes[0] === 6 ? 'K' : bytes[0] === 8 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('.f006.idx'));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 20 : 1013.25),
) < 0.0001,
);
}
assert.throws(
() =>
windMessageRanges(
parseGfsIdx(WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')),
{ overlay: 'temperature' },
),
/missing TMP/,
);
});
test('GFS preserves wind when its optional temperature is missing or malformed', async () => {
for (const failure of ['missing', 'fetch', 'decode', 'geometry', 'units']) {
const result = await fetchGfsWind({
overlay: 'temperature',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
if (url.endsWith('.idx'))
return new Response(
failure === 'missing'
? WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')
: WEATHER_IDX,
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 6 && failure === 'fetch')
throw new Error('private provider failure');
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => {
if (bytes[0] === 6 && failure === 'decode')
throw new Error('bad message');
return {
ni: 4,
nj: 3,
lo1: bytes[0] === 6 && failure === 'geometry' ? 90 : 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0] === 6 ? 293.15 : bytes[0]),
units: bytes[0] === 6 && failure !== 'units' ? 'K' : 'm/s',
};
},
});
assert.equal(result.scalar, undefined);
assert.equal(result.grid.scalar, undefined);
assert.equal(result.scalarError, 'Temperature field unavailable');
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
}
});
test('optional scalar timeout cancels only its own fetch and honors parent cancellation', async () => {
const parent = new AbortController();
let active;
const value = await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'https://fixed.test/field',
timeoutMs: 5,
signal: parent.signal,
decodeImpl: () => {
throw new Error('not reached');
},
fetchImpl: (_, { signal }) => {
active = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
});
assert.equal(value, undefined);
assert.equal(active.aborted, true);
assert.equal(parent.signal.aborted, false);
parent.abort();
let calls = 0;
assert.equal(
await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'x',
signal: parent.signal,
fetchImpl: () => {
calls++;
},
decodeImpl: () => {},
}),
undefined,
);
assert.equal(calls, 0);
});
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resampleWindGrid,
resampleWeatherFields,
} from '../../server/providers/wind/grid.js';
test('resamples and wraps a wind grid', () => {
const r = resampleWindGrid({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
v: new Array(12).fill(1),
dx: 90,
dy: 90,
});
assert.deepEqual([r.nx, r.ny], [4, 3]);
assert.equal(r.u[0], 0);
assert.equal(r.u[3], 3);
assert.equal(r.u[4], 4);
});
const component = (values, extra = {}) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(values),
...extra,
});
test('weather resampling converts temperature and MSL pressure on the wind geometry', () => {
const u = component(3);
const v = component(-4);
for (const [overlay, raw, units, expected, outUnits] of [
['temperature', 293.15, 'K', 20, '°C'],
['pressure', 101325, 'Pa', 1013.25, 'hPa'],
]) {
const result = resampleWeatherFields({
u,
v,
scalar: component(raw, { units }),
overlay,
targetDx: 90,
});
assert.equal(result.scalar.kind, overlay);
assert.equal(result.scalar.units, outUnits);
assert.deepEqual([...result.grid.u], Array(12).fill(3));
assert.deepEqual([...result.grid.v], Array(12).fill(-4));
assert.ok(result.grid.scalar.every((n) => Math.abs(n - expected) < 0.0001));
}
assert.equal(
'scalar' in resampleWeatherFields({ u, v, targetDx: 90 }).grid,
false,
);
});
test('weather components cannot share a grid when shifted, truncated or non-finite', () => {
const u = component(1);
const v = component(2);
const scalar = component(273.15, { units: 'K' });
for (const bad of [
{ ...v, lo1: 90 },
{ ...v, ni: 3 },
{ ...v, dj: 45 },
]) {
assert.throws(() => resampleWeatherFields({ u, v: bad }), /Mismatched/);
}
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, la1: 89 },
overlay: 'temperature',
}),
/Mismatched/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, units: '°C' },
overlay: 'temperature',
}),
/units/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: [1] },
overlay: 'temperature',
}),
/geometry/,
);
const invalid = component(1);
invalid.values[5] = NaN;
assert.throws(
() => resampleWeatherFields({ u, v: invalid, targetDx: 180 }),
/values/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: invalid.values },
overlay: 'temperature',
}),
/values/,
);
assert.throws(
() => resampleWeatherFields({ u, v, overlay: 'dewpoint' }),
/Unknown/,
);
});
test('optional degradation cannot hide invalid wind geometry', async () => {
const { resampleWeatherSnapshot } =
await import('../../server/providers/wind/grid.js');
const u = component(1);
const v = component(2);
const result = resampleWeatherSnapshot({
u,
v,
overlay: 'pressure',
targetDx: 90,
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.throws(
() =>
resampleWeatherSnapshot({ u, v: { ...v, la1: 89 }, overlay: 'pressure' }),
/Mismatched/,
);
assert.throws(
() =>
resampleWindGrid({ ...u, u: u.values, v: v.values, dx: 0.25, dy: 0.25 }),
/budget/,
);
});
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ifsObjectUrls,
parseIfsIndex,
ifsWindRanges,
nearestIfsStep,
selectLatestIfsCycle,
fetchIfsWind,
} from '../../server/providers/wind/ifs.js';
import { nearestGfsStep } from '../../server/providers/wind/catalog.js';
test('IFS inventory selects only 10 m components and distinct index suffix', () => {
const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 });
assert.match(urls.index, /9h-oper-fc\.index$/);
assert.equal(urls.index.includes('.grib2.index'), false);
assert.deepEqual(
ifsWindRanges(
parseIfsIndex(
'{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}',
),
),
{ u: { start: 20, end: 29 }, v: { start: 40, end: 54 } },
);
assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
assert.equal(nearestIfsStep(8, 6), 9);
assert.equal(nearestIfsStep(200, 6), 144);
assert.equal(nearestIfsStep(151, 0), 150);
assert.equal(nearestGfsStep(8.2), 8);
});
test('IFS 2t and msl companions use the same issue/valid object as 10u and 10v', async () => {
const entries = ['10u', '10v', '2t', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchIfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 4 ? 283.15 : bytes[0] === 6 ? 100000 : bytes[0],
),
units: bytes[0] === 4 ? 'K' : bytes[0] === 6 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('-9h-oper-fc.index'));
assert.equal(result.cycle.runIso, '2026-09-15T06:00:00.000Z');
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 10 : 1000),
) < 0.0001,
);
}
assert.throws(
() =>
ifsWindRanges(
parseIfsIndex(
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join('\n'),
),
{ overlay: 'pressure' },
),
/missing msl/,
);
});
test('IFS optional pressure refusal preserves same-cycle wind', async () => {
const entries = ['10u', '10v', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
const result = await fetchIfsWind({
overlay: 'pressure',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 4) return new Response('Unavailable', { status: 503 });
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0]),
}),
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
});
+657
View File
@@ -0,0 +1,657 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { windProxy } from '../../server/providers/wind.js';
/** Boot a mounted provider middleware without a real server. */
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
assert.equal(routes.size, 1);
const handler = [...routes.values()][0];
return async (url = '/', method = 'GET') => {
const res = {
statusCode: 200,
headers: {},
headersSent: false,
writeHead(status, headers) {
this.statusCode = status;
for (const [name, value] of Object.entries(headers || {}))
this.headers[name.toLowerCase()] = value;
this.headersSent = true;
},
end(body) {
this.body = body;
this.headersSent = true;
},
};
await handler({ url, method }, res);
return res;
};
}
const IDX = [
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
'3:10:d=2026091400:HGT:surface:anl:',
].join('\n');
const DECODED_U = {
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
};
const DECODED_V = {
...DECODED_U,
values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
};
/** Resampled-grid metadata shared by the injected model fakes. */
const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 };
/** Fetch double: `.idx` text, and range bodies of the requested length. */
function makeFetch({ fail = false, counter } = {}) {
return async (url, options = {}) => {
if (counter) counter.calls += 1;
if (fail) throw new Error('gfs upstream down');
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
const length = Number(match[2]) - Number(match[1]) + 1;
return new Response(new Uint8Array(length), { status: 206 });
};
}
function proxy(options = {}) {
return windProxy({
now: () => Date.UTC(2026, 8, 14, 12),
targetDx: 90,
fetchImpl: makeFetch(),
decodeImpl: async (buffer) =>
buffer[0] === undefined ? DECODED_U : DECODED_U,
...options,
});
}
test('wind manifest describes the GFS cycle and resampled grid', async () => {
const request = install(proxy());
const res = await request('/');
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.body);
assert.equal(body.schemaVersion, 1);
assert.equal(body.model, 'gfs');
assert.equal(body.cycle.hour, 6);
assert.equal(body.cycle.date, '20260914');
assert.equal(body.units, 'm/s');
assert.deepEqual(body.grid, {
nx: 4,
ny: 3,
lo1: 0,
la1: 90,
dx: 90,
dy: 90,
});
assert.match(
body.gridUrl,
/^\/api\/wind\/grid\/gfs-20260914-6-f6-90\.bin\?model=gfs$/,
);
assert.equal(body.stale, false);
});
test('wind grid route returns Float32 U then V', async () => {
let u = 0;
const request = install(
proxy({
decodeImpl: async () => {
u += 1;
return u === 1 ? DECODED_U : DECODED_V;
},
}),
);
const manifest = JSON.parse((await request('/')).body);
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(res.statusCode, 200);
assert.equal(res.headers['content-type'], 'application/octet-stream');
assert.equal(res.body.length, 4 * 3 * 4 * 2);
const floats = new Float32Array(
res.body.buffer,
res.body.byteOffset,
res.body.length / 4,
);
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
});
test('wind status omits the grid url', async () => {
const request = install(proxy());
await request('/');
const body = JSON.parse((await request('/status')).body);
assert.equal('gridUrl' in body, false);
assert.equal(body.model, 'gfs');
});
test('wind refresh is cached within the TTL and refetches after it', async () => {
const counter = { calls: 0 };
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
);
await request('/');
const afterFirst = counter.calls;
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
await request('/');
assert.equal(
counter.calls,
afterFirst,
'a second request inside the TTL must not refetch',
);
clock += 2 * 3600_000;
await request('/');
assert.equal(
counter.calls,
afterFirst + 3,
'a request past the TTL refetches',
);
});
test('wind serves last-good with stale on upstream failure', async () => {
let failing = false;
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({
now: () => clock,
fetchImpl: async (...args) => {
if (failing) throw new Error('gfs upstream down');
return makeFetch()(...args);
},
}),
);
const good = JSON.parse((await request('/')).body);
failing = true;
clock += 2 * 3600_000;
const degraded = JSON.parse((await request('/')).body);
assert.equal(degraded.stale, true);
assert.equal(degraded.reason, 'Wind upstream unavailable');
const frame = await request(good.gridUrl.replace('/api/wind', ''));
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
});
test('wind returns a JSON 404 for an unknown grid', async () => {
const request = install(proxy());
await request('/');
const res = await request('/grid/nope.bin');
assert.equal(res.statusCode, 404);
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
});
test('wind grid ids and URLs are model-scoped', async () => {
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
ifs: async () => ({
cycle: { date: '20260914', hour: 0 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
},
}),
);
const gfs = JSON.parse((await request('/')).body);
const ifs = JSON.parse((await request('/?model=ifs')).body);
assert.match(gfs.gridUrl, /gfs-20260914-6-f0-90\.bin\?model=gfs$/);
assert.match(ifs.gridUrl, /ifs-20260914-0-f0-90\.bin\?model=ifs$/);
// Each model's grid resolves through its own model parameter.
assert.equal(
(await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
assert.equal(
(await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind rejects a non-finite grid instead of caching it', async () => {
const bad = new Float32Array(12);
bad[3] = Number.POSITIVE_INFINITY;
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: bad, v: new Float32Array(12) },
}),
},
}),
);
const body = JSON.parse((await request('/')).body);
assert.equal(body.unavailable, true);
assert.equal(body.reason, 'Wind upstream unavailable');
});
test('wind rejects unknown models, paths and methods before acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error();
},
},
}),
);
assert.equal((await request('/?model=toString')).statusCode, 400);
assert.equal((await request('/secret')).statusCode, 404);
assert.equal((await request('/manifest', 'POST')).statusCode, 405);
assert.equal((await request('/grid/unknown.bin')).statusCode, 404);
assert.equal(calls, 0);
});
test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(step),
v: new Float32Array(12),
},
}),
},
}),
);
const first = JSON.parse((await request('/')).body);
clock += 3600_000;
step = 7;
const second = JSON.parse((await request('/')).body);
assert.notEqual(first.gridUrl, second.gridUrl);
assert.equal(
(await request(first.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error('private upstream detail');
},
},
}),
);
const results = await Promise.all([request('/'), request('/')]);
assert.equal(calls, 1);
assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable');
await request('/');
assert.equal(calls, 1);
});
test('shared upstream work survives one disconnect and aborts when the final client leaves', async () => {
const { EventEmitter } = await import('node:events');
let handler;
let signal;
let loads = 0;
const plugin = proxy({
models: {
gfs: ({ signal: next }) => {
loads++;
signal = next;
return new Promise((resolve, reject) =>
next.addEventListener('abort', () => reject(next.reason), {
once: true,
}),
);
},
},
});
plugin.configureServer({
middlewares: {
use: (_path, value) => {
handler = value;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
const a = handler({ url: '/manifest', method: 'GET' }, first);
const b = handler({ url: '/manifest', method: 'GET' }, second);
assert.equal(loads, 1);
first.emit('close');
assert.equal(signal.aborted, false);
second.emit('close');
assert.equal(signal.aborted, true);
await Promise.all([a, b]);
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
function weatherModel({ overlay = 'none', step = 6 } = {}) {
const scalar =
overlay === 'none'
? null
: {
kind: overlay,
units: overlay === 'temperature' ? '°C' : 'hPa',
level:
overlay === 'temperature' ? '2 m above ground' : 'mean sea level',
};
return {
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m above ground',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(1),
v: new Float32Array(12).fill(2),
...(scalar
? {
scalar: new Float32Array(12).fill(
overlay === 'temperature' ? 20 : 1013.25,
),
}
: {}),
},
...(scalar ? { scalar } : {}),
};
}
test('weather variants cache independently, bind binary URLs, and retain two generations per slot', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const calls = [];
const model =
(name) =>
async ({ overlay }) => {
calls.push(`${name}:${overlay}`);
return weatherModel({ overlay, step });
};
const request = install(
proxy({
now: () => clock,
models: { gfs: model('gfs'), ifs: model('ifs') },
}),
);
const slots = [];
for (const name of ['gfs', 'ifs'])
for (const overlay of ['none', 'temperature', 'pressure']) {
const query = `?model=${name}&overlay=${overlay}`;
const [a, b] = await Promise.all([
request(`/manifest${query}`),
request(`/manifest${query}`),
]);
const manifest = JSON.parse(a.body);
assert.deepEqual(JSON.parse(b.body), manifest);
assert.equal(
manifest.scalar?.kind,
overlay === 'none' ? undefined : overlay,
);
const binary = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(binary.body.length, 12 * (overlay === 'none' ? 8 : 12));
const floats = new Float32Array(
binary.body.buffer,
binary.body.byteOffset,
binary.body.length / 4,
);
assert.deepEqual(
[...floats.slice(0, 24)],
[...Array(12).fill(1), ...Array(12).fill(2)],
);
if (overlay !== 'none') {
assert.deepEqual(
[...floats.slice(24)],
Array(12).fill(overlay === 'temperature' ? 20 : 1013.25),
);
assert.equal(
(
await request(
manifest.gridUrl
.replace('/api/wind', '')
.replace(`&overlay=${overlay}`, ''),
)
).statusCode,
404,
);
}
slots.push({ query, first: manifest.gridUrl });
}
assert.equal(
calls.length,
6,
'one shared acquisition for each bounded variant',
);
for (const slot of slots) await request(`/manifest${slot.query}`);
assert.equal(calls.length, 6);
clock += 3600_000;
step = 7;
for (const slot of slots)
slot.second = JSON.parse(
(await request(`/manifest${slot.query}`)).body,
).gridUrl;
for (const slot of slots)
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
200,
);
clock += 3600_000;
step = 8;
for (const slot of slots) {
await request(`/manifest${slot.query}`);
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
404,
);
assert.equal(
(await request(slot.second.replace('/api/wind', ''))).statusCode,
200,
);
}
});
test('overlay failures retain only their own last-good scalar and unknown overlays never acquire', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let failing = false;
let calls = 0;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
calls++;
if (failing && overlay !== 'none') throw new Error('private detail');
return weatherModel({ overlay });
},
},
}),
);
const first = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
await request('/manifest');
failing = true;
clock += 3600_000;
const stale = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(stale.stale, true);
assert.equal(stale.gridUrl, first.gridUrl);
assert.equal(stale.scalar.kind, 'temperature');
assert.equal(
JSON.parse((await request('/manifest?overlay=pressure')).body).unavailable,
true,
);
assert.equal(JSON.parse((await request('/manifest')).body).stale, false);
const before = calls;
for (const overlay of ['toString', 'humidity', 'temperature,pressure']) {
assert.equal(
(await request(`/manifest?overlay=${overlay}`)).statusCode,
400,
);
}
assert.equal(calls, before);
});
test('weather provider rejects malformed scalar metadata, lengths and non-finite values', async () => {
const mutations = [
(value) => {
value.scalar.units = 'K';
},
(value) => {
value.grid.scalar = new Float32Array(11);
},
(value) => {
value.grid.scalar[3] = Infinity;
},
(value) => {
value.grid.v = new Float32Array(1);
},
];
for (const mutate of mutations) {
const request = install(
proxy({
models: {
gfs: async ({ overlay }) => {
const value = weatherModel({ overlay });
mutate(value);
return value;
},
},
}),
);
const manifest = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(manifest.unavailable, true);
assert.equal(manifest.gridUrl, undefined);
}
});
test('disconnecting an overlay request does not abort a different variant', async () => {
const { EventEmitter } = await import('node:events');
let handler;
const pending = new Map();
proxy({
models: {
gfs: ({ overlay, signal }) =>
new Promise((resolve, reject) => {
pending.set(overlay, { signal, resolve });
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
},
}).configureServer({
middlewares: {
use: (_path, fn) => {
handler = fn;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
second.writeHead = () => {};
second.end = (body) => {
second.body = body;
};
const a = handler(
{ url: '/manifest?overlay=temperature', method: 'GET' },
first,
);
const b = handler(
{ url: '/manifest?overlay=pressure', method: 'GET' },
second,
);
first.emit('close');
assert.equal(pending.get('temperature').signal.aborted, true);
assert.equal(pending.get('pressure').signal.aborted, false);
pending.get('pressure').resolve(weatherModel({ overlay: 'pressure' }));
await Promise.all([a, b]);
assert.equal(JSON.parse(second.body).scalar.kind, 'pressure');
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
test('optional scalar failure serves wind and recovery has a distinct immutable grid URL', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let missing = true;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
if (!missing) return weatherModel({ overlay });
return {
...weatherModel(),
scalarError: 'Temperature field unavailable',
};
},
},
}),
);
const partial = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(partial.overlay, 'temperature');
assert.equal(partial.unavailable, false);
assert.equal(partial.stale, false);
assert.equal(partial.scalar, undefined);
assert.equal(partial.scalarError, 'Temperature field unavailable');
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
clock += 3600_000;
missing = false;
const complete = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.deepEqual(
complete.cycle,
partial.cycle,
'recovery is the same cycle without mixing components',
);
assert.equal(complete.scalar.kind, 'temperature');
assert.equal(complete.scalarError, undefined);
assert.notEqual(
complete.gridUrl,
partial.gridUrl,
'8-byte and 12-byte payloads cannot reuse immutable URL',
);
assert.equal(
(await request(complete.gridUrl.replace('/api/wind', ''))).body.length,
12 * 12,
);
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
});