mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream/main into feature/catalonia-cctv-cameras
Keeps PR #595's Catalonia CCTV pack in sync with upstream's DelDOT CCTV pack (#699) and the wind/weather doc updates from #617. The sources.js conflict was a false positive from git's line-based diff interleaving two structurally similar but independent loader functions (loadCataloniaSourcesFromOpenData / loadDelDOTSourcesFromOpenData); resolved by keeping both as complete, separate functions. Full unit suite passes (4743/4743, 1 pre-existing Node-version skip).
This commit is contained in:
commit
5b4931572a
213 files changed
+44219
-881
No files matched your search
@@ -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);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
* @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,21 @@ 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: [],
|
||||
},
|
||||
};
|
||||
|
||||
const EARTH_R_KM = 6371;
|
||||
@@ -163,12 +179,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 +199,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 });
|
||||
}
|
||||
}
|
||||
@@ -292,8 +326,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.
|
||||
|
||||
@@ -195,9 +195,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
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -77,6 +77,15 @@ 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: 'overpass',
|
||||
html:
|
||||
@@ -147,6 +156,10 @@ export const DATA_CREDITS = [
|
||||
'CCTV cameras & 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:
|
||||
@@ -220,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',
|
||||
|
||||
+30
-69
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
+203
-4
@@ -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',
|
||||
@@ -366,6 +515,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 +539,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 +800,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 +833,7 @@ export function parseStoredLayerState(raw) {
|
||||
return null;
|
||||
return normalizeLayerState({
|
||||
enabledLayerIds: parsed.l,
|
||||
options: parsed.o,
|
||||
options: withoutShareOnlyOptions(parsed.o),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
+1088
-274
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,137 @@ 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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user