Merge upstream/main into pr/panel-resize

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-23 19:00:18 +01:00
commit 11603729dc
234 files changed
+45360 -944

No files matched your search

+113
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) {
@@ -217,3 +218,115 @@ test('the separate viewer export imports without constructing a browser viewer',
assert.equal(typeof createApplicationViewer, 'function');
assert.throws(() => createApplicationViewer({}), /containers are required/);
});
function pinchFixture({ zoomEventTypes } = {}) {
const handlers = new Set();
const relayed = [];
const container = {
addEventListener(type, handler, options) {
assert.equal(type, 'wheel');
assert.deepEqual(options, { capture: true, passive: false });
handlers.add(handler);
},
removeEventListener(type, handler, capture) {
assert.equal(type, 'wheel');
assert.equal(capture, true);
handlers.delete(handler);
},
emit(event) {
for (const handler of handlers) handler(event);
},
};
const canvas = {
dispatchEvent(event) {
// A real canvas dispatch crosses the container's capture listener again.
container.emit(event);
relayed.push(event);
return true;
},
};
const controller = { zoomEventTypes };
const viewer = {
container,
canvas,
scene: { screenSpaceCameraController: controller },
};
const createEvent = (init = {}) => ({
deltaX: 0,
deltaY: 0,
deltaZ: 0,
deltaMode: 0,
screenX: 0,
screenY: 0,
clientX: 0,
clientY: 0,
ctrlKey: false,
defaultPrevented: false,
propagationStopped: false,
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {
this.propagationStopped = true;
},
...init,
});
return { viewer, controller, container, handlers, relayed, createEvent };
}
test('trackpad pinch adds the Ctrl+wheel binding without replacing Cesium inputs', () => {
const original = [1, 2, 3];
const f = pinchFixture({ zoomEventTypes: original });
const dispose = installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
assert.deepEqual(f.controller.zoomEventTypes.slice(0, 3), original);
assert.equal(f.controller.zoomEventTypes.length, 4);
const plain = f.createEvent({ deltaY: 2 });
f.container.emit(plain);
assert.equal(plain.defaultPrevented, false);
const lineMode = f.createEvent({ ctrlKey: true, deltaY: 2, deltaMode: 1 });
f.container.emit(lineMode);
assert.equal(lineMode.defaultPrevented, false);
assert.equal(f.relayed.length, 0);
dispose();
dispose();
assert.equal(f.controller.zoomEventTypes, original);
assert.equal(f.handlers.size, 0);
});
test('trackpad pinch relays pixel deltas once with bounded amplification', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent: (_type, init) => f.createEvent(init),
});
const small = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(small);
assert.equal(small.defaultPrevented, true);
assert.equal(small.propagationStopped, true);
assert.equal(f.relayed.length, 1);
assert.equal(f.relayed[0].deltaY, 16);
assert.equal(f.relayed[0].ctrlKey, true);
const large = f.createEvent({ ctrlKey: true, deltaY: -100 });
f.container.emit(large);
assert.equal(f.relayed.length, 2);
assert.equal(f.relayed[1].deltaY, -120);
});
test('trackpad pinch falls back to the original event when relay construction fails', () => {
const f = pinchFixture({ zoomEventTypes: [1, 2, 3] });
installTrackpadPinchZoom(f.viewer, {
createWheelEvent() {
throw new Error('unsupported');
},
});
const event = f.createEvent({ ctrlKey: true, deltaY: 2 });
f.container.emit(event);
assert.equal(event.defaultPrevented, false);
assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0);
});
+34 -1
View File
@@ -1,3 +1,7 @@
import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.js';
import { createCyclonesLayer } from '../layers/cyclones/index.js';
import { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
@@ -9,6 +13,7 @@ import { createApplicationRadio } from './layers/radio.js';
import { createApplicationTraffic } from './layers/traffic.js';
import { createApplicationBikeshare } from './layers/bikeshare.js';
import { createApplicationDirections } from './layers/directions.js';
import { createApplicationRecentImagery } from './layers/recentImagery.js';
import { createApplicationTransit } from './layers/transit.js';
import { createApplicationInstallations } from './layers/militaryInstallations.js';
import { createApplicationSatellites } from './layers/satellites.js';
@@ -42,6 +47,9 @@ const SOURCE_METHODS = Object.freeze({
launches: ['getLaunches', 'getActiveTle'],
alpr: ['fetch'],
firms: ['getSnapshot'],
wind: ['getSnapshot'],
weather: ['getSnapshot'],
cyclones: ['getSnapshot'],
earthquakes: ['getSnapshot'],
cables: ['fetch'],
});
@@ -72,9 +80,11 @@ export function createApplicationCatalog({
throw new TypeError(`Invalid catalog source: ${name}`);
}
const militaryRegistry = createMilitaryRegistry();
const weatherClock = createWeatherClock();
const dispose = () => {
signal.removeEventListener('abort', dispose);
militaryRegistry.dispose();
weatherClock.destroy();
};
signal.addEventListener('abort', dispose, { once: true });
try {
@@ -120,6 +130,7 @@ export function createApplicationCatalog({
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(),
createApplicationRecentImagery(),
vessels,
installations,
createApplicationAwareness({
@@ -128,6 +139,23 @@ export function createApplicationCatalog({
vessels,
installations,
}),
createWindLayer({ feed: sources.wind, clock: weatherClock }),
createWeatherLayer({
feed: sources.weather,
id: 'weather-radar',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-satellite',
clock: weatherClock,
}),
createWeatherLayer({
feed: sources.weather,
id: 'weather-lightning',
clock: weatherClock,
}),
createCyclonesLayer({ feed: sources.cyclones }),
...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }),
createApplicationFirms({
@@ -141,7 +169,12 @@ export function createApplicationCatalog({
],
metadata,
);
return Object.freeze({ ...catalog, militaryRegistry, surface });
return Object.freeze({
...catalog,
militaryRegistry,
surface,
weatherClock,
});
} catch (error) {
dispose();
throw error;
+17 -1
View File
@@ -39,7 +39,19 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 21);
assert.equal(first.layers.length, 27);
assert.notEqual(first.weatherClock, second.weatherClock);
await first.weatherClock.setTarget('2026-09-21T12:00:00.000Z');
assert.match(
first.get('wind').getRowControls().info,
/Forecast · does not follow history/,
);
assert.equal(second.get('wind').getRowControls().summary.status, null);
for (const id of ['weather-radar', 'weather-satellite', 'weather-lightning'])
assert.equal(
first.get(id).getDiagnostics().clock.target,
'2026-09-21T12:00:00.000Z',
);
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
@@ -82,6 +94,10 @@ test('catalogs construct distinct layers and classification from their supplied
assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false);
assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true);
a.abort();
assert.equal(
await first.weatherClock.setTarget('2026-09-21T13:00:00.000Z'),
false,
);
assert.equal(callsA[0].aborted, true);
assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false);
assert.equal(callsB[0].aborted, false);
+3 -1
View File
@@ -20,7 +20,9 @@ export function createApplicationData({
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
const presentation = new LayerPresentation(dataManager);
const presentation = new LayerPresentation(dataManager, {
weatherClock: catalog?.weatherClock,
});
defer(() => presentation.destroy());
onData?.(dataManager);
if (!catalog?.layers || !catalog?.metadata)
+9
View File
@@ -7,11 +7,13 @@ export class LayerPresentation {
constructor(
manager,
{
weatherClock,
requestRender = governorRequestRender,
invalidateDetection = markDetectionSourcesChanged,
} = {},
) {
this.manager = manager;
this.weatherClock = weatherClock;
this._panel = null;
this.pendingVisible = false;
this._unsubscribe = manager.subscribeActivity((change) => {
@@ -35,6 +37,7 @@ export class LayerPresentation {
get panel() {
if (!this._panel)
this._panel = new LayerPanel({
weatherClock: this.weatherClock,
getLayers: () => this.manager.getAll(),
isEnabled: (id) => this.manager.isEnabled(id),
setEnabled: (id, enabled, options) =>
@@ -67,6 +70,12 @@ export class LayerPresentation {
mount(container) {
this.panel.mount(container);
}
/** Hand the Recent Imagery readout factory to the panel (see LayerPanel). */
attachRecentImagery(factory) {
// Detaching never builds a panel that teardown already released.
if (factory) this.panel.attachRecentImagery(factory);
else this._panel?.attachRecentImagery(null);
}
refresh() {
this._panel?._refreshTogglePanel();
}
+19
View File
@@ -0,0 +1,19 @@
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js';
/**
* Construct the Recent Imagery layer with the real GIBS renderer and the
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
return createRecentImageryLayer({
renderer: createRecentImageryRenderer({
requestRender: governorRequestRender,
}),
thumbnails: createThumbnailLoader(),
});
}
+5 -1
View File
@@ -1,6 +1,9 @@
import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../app/viewer.js';
import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -52,6 +55,7 @@ export async function createApplicationScene({
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
+2
View File
@@ -10,6 +10,7 @@ import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureWindSource } from '../data/wind.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
@@ -21,6 +22,7 @@ const configure = {
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
wind: configureWindSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
+39
View File
@@ -1,6 +1,8 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initDrawTool } from '../annotations/drawTool.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
@@ -53,6 +55,43 @@ export function createApplicationTools({
// lifetime rather than to whoever last pressed the button.
const drawTool = initDrawTool({ viewer, annotations });
defer(() => drawTool?.destroy());
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
// panel lives on the right rail, so both belong to the application
// lifetime. The tileset lets the layer drape while the globe is hidden.
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
if (recentImagery) {
recentImagery.attachTileset(tileset);
const imageryBoxTool = initImageryBoxTool({
viewer,
onBox: (box) => recentImagery.setBox(box),
onCancel: (reason, message, box) => {
if (message) recentImagery.reportBoxRefusal(message, box);
},
onActive: (active) => recentImagery.setToolActive(active),
// The tool takes Escape in a capture listener, so the panel's order
// (clear a preview before cancelling the tool) is applied here.
onEscape: () => recentImagery.clearPreview(),
});
// The readout mounts in its rail body through the layer panel, like the
// weather readout.
data.presentation.attachRecentImagery((container) =>
createRecentImageryPanel({
container,
viewer,
layer: recentImagery,
tool: imageryBoxTool,
}),
);
// The live gate's handle (scripts/qa-recent-imagery.mjs).
const recentImageryHandle = { layer: recentImagery, tool: imageryBoxTool };
window.__gevRecentImagery = recentImageryHandle;
defer(() => {
if (window.__gevRecentImagery === recentImageryHandle)
delete window.__gevRecentImagery;
data.presentation.attachRecentImagery(null);
imageryBoxTool?.destroy();
});
}
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
+101
View File
@@ -1,5 +1,106 @@
import * as Cesium from 'cesium';
const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120;
function boundedPinchDelta(delta) {
if (!Number.isFinite(delta) || delta === 0) return delta;
return (
Math.sign(delta) *
Math.min(Math.abs(delta) * PINCH_ZOOM_MULTIPLIER, MAX_PINCH_PIXEL_DELTA)
);
}
/**
* Add browser trackpad pinch to Cesium's zoom inputs and return its disposer.
* Browsers expose this gesture as a small pixel-mode Ctrl+wheel event.
*/
export function installTrackpadPinchZoom(
viewer,
{ createWheelEvent = (type, init) => new WheelEvent(type, init) } = {},
) {
const controller = viewer?.scene?.screenSpaceCameraController;
const container = viewer?.container;
const canvas = viewer?.canvas;
if (!controller || !container || !canvas)
throw new TypeError('A complete Cesium viewer is required');
const originalZoomEventTypes = controller.zoomEventTypes;
const zoomEventTypes = Array.isArray(originalZoomEventTypes)
? originalZoomEventTypes
: originalZoomEventTypes === undefined
? []
: [originalZoomEventTypes];
const alreadyHandlesControlWheel = zoomEventTypes.some(
(binding) =>
binding?.eventType === Cesium.CameraEventType.WHEEL &&
binding?.modifier === Cesium.KeyboardEventModifier.CTRL,
);
const configuredZoomEventTypes = alreadyHandlesControlWheel
? originalZoomEventTypes
: [
...zoomEventTypes,
{
eventType: Cesium.CameraEventType.WHEEL,
modifier: Cesium.KeyboardEventModifier.CTRL,
},
];
if (!alreadyHandlesControlWheel)
controller.zoomEventTypes = configuredZoomEventTypes;
const relayedEvents = new WeakSet();
const relayPinch = (event) => {
if (
!event.ctrlKey ||
relayedEvents.has(event) ||
event.deltaMode !== 0 ||
!Number.isFinite(event.deltaY) ||
event.deltaY === 0
)
return;
let relayed;
try {
relayed = createWheelEvent('wheel', {
deltaX: event.deltaX,
deltaY: boundedPinchDelta(event.deltaY),
deltaZ: event.deltaZ,
deltaMode: event.deltaMode,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: true,
bubbles: true,
cancelable: true,
view: globalThis.window,
});
} catch {
// The registered Ctrl+wheel binding can still consume the original.
return;
}
relayedEvents.add(relayed);
event.preventDefault();
event.stopPropagation();
canvas.dispatchEvent(relayed);
};
container.addEventListener('wheel', relayPinch, {
capture: true,
passive: false,
});
let active = true;
return () => {
if (!active) return;
active = false;
container.removeEventListener('wheel', relayPinch, true);
if (
!alreadyHandlesControlWheel &&
controller.zoomEventTypes === configuredZoomEventTypes
)
controller.zoomEventTypes = originalZoomEventTypes;
};
}
/** Create the standard globe viewer in caller-owned, visible containers. */
export function createApplicationViewer({ container, creditContainer }) {
if (!container || !creditContainer)
+22 -4
View File
@@ -205,6 +205,7 @@ const RECOGNIZED = new Set([
// rail
'#right-context-rail',
'#right-context-rail.layout-focus',
'#right-context-rail[data-rail-measuring]',
// tray
'#command-dock .dock-popover-content',
'#command-dock #location-bar .dock-popover-content',
@@ -400,20 +401,26 @@ test('the model refuses every cascade construct it cannot resolve', () => {
complaints.push(`nested media queries on "${part}": ${rule.media.join(' && ')}`);
}
for (const decl of guarded) {
if (decl.important) complaints.push(`!important on ${decl.prop} of "${part}"`);
// Measurement is synchronous and restored before returning or painting.
// Only these two exact declarations earn the exemption tested below.
const railMeasurement = part === '#right-context-rail[data-rail-measuring]'
&& ((decl.prop === 'height' && decl.value === 'auto')
|| (decl.prop === 'max-height' && decl.value === 'none'));
if (decl.important && !railMeasurement) complaints.push(`!important on ${decl.prop} of "${part}"`);
if (decl.prop === 'inset' || decl.prop === 'margin' || decl.prop === 'all'
|| decl.prop.startsWith('inset-') || decl.prop.startsWith('margin-block')) {
complaints.push(`shorthand ${decl.prop} on "${part}" — the model reads longhands only`);
}
if (decl.prop === 'height' || decl.prop === 'max-height') {
// A capped height can override `bottom` and invalidate the measured
// dock/credit constants. Two exemptions, each earned by a test below:
// dock/credit constants. Exemptions are each earned by a test below:
// the rail's own max-height (resolved to `none` across the whole
// modelled band by the rail clearance test) and `.layout-focus`
// (proven inapplicable at <=720px by the mobile-mode test).
// (proven inapplicable at <=720px by the mobile-mode test), plus
// synchronous measurement, which is removed before any paint.
const railOwn = part === '#right-context-rail' && decl.prop === 'max-height';
const railFocus = part === '#right-context-rail.layout-focus';
if (!railOwn && !railFocus) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
if (!railOwn && !railFocus && !railMeasurement) complaints.push(`${decl.prop}: ${decl.value} on "${part}"`);
}
if (decl.prop === 'transform' && /translateY|translate3d|matrix|scale\(/.test(decl.value)) {
const identity = decl.value === 'translateY(0) scale(1)';
@@ -490,6 +497,17 @@ test('the full-width rail cannot inherit a height that overrides its floor', ()
assert.match(mobileBranch, /stack\.style\.removeProperty\('--right-stack-max-height'\)/);
});
test('right rail measurement cannot persist into a painted attribution layout', () => {
const rail = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'rightPanelRail.js'), 'utf8');
const start = rail.indexOf("stack.setAttribute('data-rail-measuring', '')");
const end = rail.indexOf("stack.removeAttribute('data-rail-measuring')", start);
assert.ok(start > rail.indexOf("layoutMode = 'mobile'"));
assert.ok(end > start);
const measurement = rail.slice(start, end);
assert.match(measurement, /try \{[\s\S]*\} finally \{/);
assert.doesNotMatch(measurement, /\b(?:await|return|yield)\b|requestAnimationFrame|setTimeout/);
});
// ── Clearance ───────────────────────────────────────────────────────────────
test('every open dock tray clears the required credit at every modelled viewport', () => {
+25
View File
@@ -1600,6 +1600,31 @@ test('vessel interaction wire: a sibling-owned pick wins without selection mutat
}
});
for (const card of ['live', 'stale', 'absent', 'covered']) {
test(`vessel interaction wire: ${card} foreground card over registered cyclone`, () => {
const id = 'cyclone:fixture:cone:0';
registerPickOwner('weather-cyclones', (pickedId) => pickedId === id);
const harness = installWireHarness({ id });
const requests = [];
harness.windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (event) => requests.push(event.detail));
const host = hostWithCardHit(card === 'live' ? `vessel:${harness.record.mmsi}` : card === 'stale' ? 'vessel:999999999' : null);
if (card === 'covered') host.hitTest = (_x, _y, options) => {
assert.equal(options?.sourceId, undefined, 'test topmost host hit, not a covered AIS card');
return { sourceId: 'cctv', entryId: 'vessel:999999999' };
};
_setVesselOverlayHostForTest(host);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(requests.length, card === 'live' ? 1 : 0);
} finally {
_setVesselOverlayHostForTest(null);
harness.cleanup();
unregisterPickOwner('weather-cyclones');
}
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aisStreamRows,
ingestAisStreamEnvelope,
} from '../../server/providers/vessels/ais-store.js';
// ITU-R M.1371 encodes speed over ground in 0.1-knot units and course over
// ground in 0.1-degree units, reserving the top code of each field for "not
// available": SOG 1023 arrives as 102.3 knots and COG 3600 as 360 degrees.
// Both are finite numbers, so they reach consumers as ordinary readings unless
// the store rejects them here.
const positionReport = (mmsi, body) => ({
MessageType: 'PositionReport',
Message: { PositionReport: { UserID: Number(mmsi), ...body } },
MetaData: {
MMSI: mmsi,
latitude: 53.40879,
longitude: 6.19741,
time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC',
},
});
const rowFor = (mmsi) => aisStreamRows(50000).find((row) => row.mmsi === mmsi);
test('speed over ground reported as unavailable is unknown, not 102.3 knots', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('244060365', { Sog: 102.3 })),
true,
);
assert.equal(rowFor('244060365').speed, null);
});
test('course over ground reported as unavailable is unknown, not 360 degrees', () => {
assert.equal(
ingestAisStreamEnvelope(positionReport('245382000', { Cog: 360 })),
true,
);
assert.equal(rowFor('245382000').course, null);
});
test('genuine speed and course readings survive the unavailable check', () => {
ingestAisStreamEnvelope(
positionReport('341260000', { Sog: 12.9, Cog: 74.6 }),
);
const row = rowFor('341260000');
assert.equal(row.speed, 12.9);
assert.equal(row.course, 74.6);
});
test('a stopped vessel keeps its zero speed and zero course', () => {
ingestAisStreamEnvelope(positionReport('244374235', { Sog: 0, Cog: 0 }));
const row = rowFor('244374235');
assert.equal(row.speed, 0);
assert.equal(row.course, 0);
});
test('the fastest encodable reading below the unavailable code is kept', () => {
ingestAisStreamEnvelope(
positionReport('244060366', { Sog: 102.2, Cog: 359.9 }),
);
const row = rowFor('244060366');
assert.equal(row.speed, 102.2);
assert.equal(row.course, 359.9);
});
+43 -2
View File
@@ -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.
+79
View File
@@ -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
View File
@@ -22,6 +22,8 @@ import {
createBhoteKoshiEmbeddedMedia,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
import { acquireImageryComparison } from '../maps/imageryComparison.js';
import { createImagerySplit } from '../ui/imagerySplit.js';
export const BHOTE_KOSHI_LAYER_ID = 'bhote-koshi-2026';
export const BHOTE_KOSHI_OVERLAY_SOURCE_ID = 'bhote-koshi-witnesses';
@@ -964,10 +966,7 @@ export function createBhoteKoshiEventLayer({
let _enabled = false;
let _panel = null;
let _panelRefs = null;
let _splitLine = null;
let _splitHandle = null;
let _splitDragging = false;
let _splitPointerId = null;
let _splitControl = null;
let _beforeLayer = null;
let _afterLayer = null;
let _imageryLayerCollection = null;
@@ -1022,14 +1021,11 @@ export function createBhoteKoshiEventLayer({
let _playStartedAt = 0;
let _playStartProgress = 0;
let _animationFrame = null;
let _previousMapStack = null;
let _eventMapGeneration = null;
let _previousSplitPosition = null;
let _comparisonLease = null;
let _introTimer = null;
let _introGeneration = 0;
let _lastSurgePosition = null;
let _lastSurgeVisible = null;
let _lastSplitCssValue = null;
let _presentation = 'standalone';
let _sceneBeatId = null;
let _sceneBeatReveal = 0.36;
@@ -2556,79 +2552,20 @@ export function createBhoteKoshiEventLayer({
createFloodDataSource();
}
function ensureSplitLine(event) {
_splitLine = document.createElement('div');
_splitLine.id = 'bhote-koshi-split-line';
const beforeLabel = document.createElement('span');
const afterLabel = document.createElement('span');
const splitHandle = document.createElement('button');
beforeLabel.className = 'before';
afterLabel.className = 'after';
beforeLabel.textContent = 'A';
beforeLabel.title = event.imagery.before.label;
afterLabel.textContent = 'B';
afterLabel.title = event.imagery.after.label;
splitHandle.type = 'button';
splitHandle.className = 'bhote-koshi-split-handle';
splitHandle.setAttribute('role', 'slider');
splitHandle.setAttribute(
'aria-label',
'Historical reference and post-event image divider',
);
splitHandle.setAttribute('aria-valuemin', '0');
splitHandle.setAttribute('aria-valuemax', '100');
splitHandle.setAttribute('aria-orientation', 'horizontal');
splitHandle.textContent = '↔';
splitHandle.addEventListener('pointerdown', handleSplitPointerDown);
splitHandle.addEventListener('pointermove', handleSplitPointerMove);
splitHandle.addEventListener('pointerup', finishSplitPointerDrag);
splitHandle.addEventListener('pointercancel', finishSplitPointerDrag);
splitHandle.addEventListener('keydown', handleSplitKeyDown);
_splitHandle = splitHandle;
_splitLine.append(beforeLabel, splitHandle, afterLabel);
document.body.appendChild(_splitLine);
}
function setSplitFromClientX(clientX) {
const viewportWidth =
Number(document.documentElement?.clientWidth) ||
Number(window.innerWidth) ||
0;
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
setSplit(Number(clientX) / viewportWidth);
}
function handleSplitPointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
_splitDragging = true;
_splitPointerId = event.pointerId ?? null;
_splitHandle?.setPointerCapture?.(event.pointerId);
setSplitFromClientX(event.clientX);
}
function handleSplitPointerMove(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
event.preventDefault?.();
setSplitFromClientX(event.clientX);
}
function finishSplitPointerDrag(event) {
if (!_splitDragging) return;
if (_splitPointerId != null && event.pointerId !== _splitPointerId) return;
_splitHandle?.releasePointerCapture?.(event.pointerId);
_splitDragging = false;
_splitPointerId = null;
}
function handleSplitKeyDown(event) {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
event.preventDefault?.();
const step = event.shiftKey ? 0.05 : 0.01;
if (event.key === 'Home') setSplit(0);
else if (event.key === 'End') setSplit(1);
else setSplit(_split + (event.key === 'ArrowRight' ? step : -step));
function createSplitControl(event) {
_splitControl = createImagerySplit({
scene: _viewer.scene,
initialValue: _split,
id: 'bhote-koshi-split-line',
handleClass: 'bhote-koshi-split-handle',
cssProperty: '--bhote-koshi-split',
beforeTitle: event.imagery.before.label,
afterTitle: event.imagery.after.label,
ariaLabel: 'Historical reference and post-event image divider',
formatValueText: (beforePercent, afterPercent) =>
`A historical reference ${beforePercent} percent, B post-event ${afterPercent} percent`,
onChange: setSplit,
});
}
function applyCinematicCamera() {
@@ -3211,7 +3148,7 @@ export function createBhoteKoshiEventLayer({
_panelRefs.observedStatus.hidden = !imageryAvailable;
if (_panelRefs?.progressInput)
_panelRefs.progressInput.hidden = !scrubberAvailable;
if (_splitLine) _splitLine.hidden = !imageryAvailable;
_splitControl?.setVisible(imageryAvailable);
if (_beforeLayer) _beforeLayer.show = imageryAvailable;
if (_afterLayer) _afterLayer.show = imageryAvailable;
if (_panelRefs?.caveat) {
@@ -3328,24 +3265,7 @@ export function createBhoteKoshiEventLayer({
function setSplit(split) {
_split = clampUnit(split);
if (_viewer && _viewer.scene.splitPosition !== _split)
_viewer.scene.splitPosition = _split;
const cssValue = `${_split * 100}%`;
if (_lastSplitCssValue !== cssValue) {
document.documentElement.style.setProperty(
'--bhote-koshi-split',
cssValue,
);
_lastSplitCssValue = cssValue;
}
if (_splitHandle) {
const beforePercent = Math.round(_split * 100);
_splitHandle.setAttribute('aria-valuenow', String(beforePercent));
_splitHandle.setAttribute(
'aria-valuetext',
`A historical reference ${beforePercent} percent, B post-event ${100 - beforePercent} percent`,
);
}
_splitControl?.setValue(_split);
syncPanel();
renderHost.request('bhote-koshi-split');
}
@@ -3428,7 +3348,6 @@ export function createBhoteKoshiEventLayer({
async function enable(viewer, { origin = 'local-restore', signal } = {}) {
_viewer = viewer;
_enabled = true;
_previousSplitPosition = viewer.scene.splitPosition;
// Public lifecycle passes signal, not intent origin, to enable. Its atomic
// restore has already applied the scene parameters; those own presentation.
// A missing origin is passive, never authority to focus or start playback.
@@ -3437,24 +3356,20 @@ export function createBhoteKoshiEventLayer({
const event = await loadEvent();
throwIfEnableCancelled(signal, _enabled);
registerDynamicCredit(viewer, BHOTE_KOSHI_CREDIT);
_previousMapStack = _mapStackController?.getActiveId?.() || null;
if (
!sceneDirected &&
_mapStackController &&
_previousMapStack !== 'esri-imagery'
) {
await _mapStackController.setStack('esri-imagery');
if (_mapStackController) {
_comparisonLease = acquireImageryComparison(_mapStackController, {
owner: BHOTE_KOSHI_LAYER_ID,
switchPolicy: sceneDirected ? 'preserve' : 'esri',
});
await _comparisonLease.ready;
}
_eventMapGeneration = sceneDirected
? null
: (_mapStackController?.getSwitchGeneration?.() ?? null);
throwIfEnableCancelled(signal, _enabled);
await createImagery(event);
throwIfEnableCancelled(signal, _enabled);
await syncTerrainComparison();
throwIfEnableCancelled(signal, _enabled);
createFloodDataSource();
ensureSplitLine(event);
createSplitControl(event);
const panelView = createPanel(event, {
close: () =>
_dataManager?.setEnabled(BHOTE_KOSHI_LAYER_ID, false, {
@@ -3573,17 +3488,8 @@ export function createBhoteKoshiEventLayer({
?.classList.remove('bhote-event-active');
_panel = null;
_panelRefs = null;
_splitDragging = false;
_splitPointerId = null;
_splitLine?.remove();
_splitLine = null;
_splitHandle = null;
document.documentElement.style.removeProperty('--bhote-koshi-split');
_lastSplitCssValue = null;
if (_viewer?.scene && _previousSplitPosition != null) {
_viewer.scene.splitPosition = _previousSplitPosition;
}
_previousSplitPosition = null;
_splitControl?.destroy();
_splitControl = null;
_progress = 0;
_split = 0.5;
_presentation = 'standalone';
@@ -3601,20 +3507,10 @@ export function createBhoteKoshiEventLayer({
cancel?.(_sceneSeekFrame);
_sceneSeekFrame = null;
}
const previousMapStack = _previousMapStack;
const eventMapGeneration = _eventMapGeneration;
_previousMapStack = null;
_eventMapGeneration = null;
const comparisonLease = _comparisonLease;
_comparisonLease = null;
try {
if (
_mapStackController &&
previousMapStack &&
previousMapStack !== 'esri-imagery' &&
_mapStackController.getActiveId?.() === 'esri-imagery' &&
_mapStackController.getSwitchGeneration?.() === eventMapGeneration
) {
await _mapStackController.setStack(previousMapStack);
}
await comparisonLease?.release();
} finally {
renderHost.request('bhote-koshi-disable');
}
+225
View File
@@ -0,0 +1,225 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createHlsPuller,
fetchHlsBytes,
parseHlsMedia,
HLS_LIMITS,
} from '../../server/providers/cctv/stream.js';
const playlist =
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
const base = 'https://camera.example/live/list.m3u8';
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
assert.deepEqual(
parseHlsMedia(playlist, base).map((s) => s.seq),
[12, 13, 14],
);
for (const bad of [
playlist.replace('a.ts', 'https://evil.example/a.ts'),
playlist.replace('a.ts', '//evil.example/a.ts'),
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
playlist.replace('12', '-1'),
playlist.replace('2,', 'Infinity,'),
])
assert.throws(() => parseHlsMedia(bad, base));
});
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
let init;
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async (_url, options) => {
init = options;
return new Response('', {
status: 302,
headers: { Location: 'https://evil.example' },
});
},
}),
);
assert.equal(init.redirect, 'error');
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () => new Response('abcdef'),
}),
);
await assert.rejects(
fetchHlsBytes(base, {
maxBytes: 5,
fetchImpl: async () =>
new Response('a', { headers: { 'content-length': '100' } }),
}),
);
});
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
const manager = createHlsPuller({
limits: {
...HLS_LIMITS,
sessions: 1,
segmentBytes: 4,
sessionBytes: 6,
segments: 2,
pollMs: 100000,
},
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const [a, b] = await Promise.all([
manager.ensure('a', base),
manager.ensure('a', base),
]);
assert.equal(a, b);
await assert.rejects(manager.ensure('b', base));
assert.equal(await manager.waitReady(a), true);
assert.deepEqual(manager.stats(), { sessions: 1, bytes: 6 });
const text = await manager.buildPlaylist(a, 'a');
assert.match(text, /seg_0\.ts\?session=/);
assert.equal(manager.getSegment('a', 'stale-token', 0), null);
assert.equal(manager.getSegment('a', a.token, 0).length, 3);
manager.stop('a', 'stale-token');
assert.equal(manager.stats().sessions, 1);
manager.stop('a', a.token);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
let observed;
const manager = createHlsPuller({
fetchImpl: async (_url, { signal }) => {
observed = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true,
}),
);
},
});
const entry = await manager.ensure('a', base);
await manager.shutdown();
assert.equal(observed.aborted, true);
assert.equal(entry.stopping, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await assert.rejects(manager.ensure('a', base));
});
test('idle cleanup stops all polling without a background sweep', async () => {
let calls = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
fetchImpl: async (url) => {
calls++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
await manager.ensure('a', base);
await new Promise((r) => setTimeout(r, 50));
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
const stoppedCalls = calls;
await new Promise((r) => setTimeout(r, 30));
assert.equal(calls, stoppedCalls);
await manager.shutdown();
});
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
const before = await manager.buildPlaylist(entry, 'a');
assert.match(before, /seg_0\.ts/);
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
await new Promise((r) => setTimeout(r, 30));
const after = await manager.buildPlaylist(entry, 'a');
assert.match(
after,
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('upstream discontinuity tags survive the media parser', () => {
const parsed = parseHlsMedia(
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
base,
);
assert.equal(parsed[0].discontinuity, false);
assert.equal(parsed[1].discontinuity, true);
});
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
let current = playlist;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 5 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? current : 'abc'),
});
const entry = await manager.ensure('a', base);
await manager.waitReady(entry);
current = playlist.replaceAll('.ts', '.ts?generation=2');
await new Promise((r) => setTimeout(r, 30));
assert.match(
await manager.buildPlaylist(entry, 'a'),
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
);
await manager.shutdown();
});
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
let downloads = 0;
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
fetchImpl: async (url) => {
downloads++;
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
},
});
const [a, b] = await Promise.all([
manager.ensure('camera', base, 'viewer-a'),
manager.ensure('camera', base, 'viewer-b'),
]);
assert.equal(a, b);
await manager.waitReady(a);
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
assert.equal(a.leases.size, 2);
await assert.rejects(manager.ensure('camera', base, 'viewer-c'));
manager.release('camera', 'viewer-a');
assert.equal(manager.stats().sessions, 1);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
assert.match(
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
/lease=viewer-b/,
);
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
assert.equal(b.controller.signal.aborted, false);
manager.release('camera', 'viewer-b');
assert.equal(b.controller.signal.aborted, true);
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
await manager.shutdown();
});
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
const manager = createHlsPuller({
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
fetchImpl: async (url) =>
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
});
const entry = await manager.ensure('camera', base, 'abandoned');
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
await manager.ensure('camera', base, 'active');
await new Promise((r) => setTimeout(r, 50));
assert.equal(entry.leases.has('abandoned'), false);
assert.equal(entry.leases.has('active'), true);
assert.equal(entry.controller.signal.aborted, false);
await manager.shutdown();
});
+454
View File
@@ -0,0 +1,454 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import {
cycloneProxy,
parseCycloneStatus,
attachCycloneGeometry,
} from '../../server/providers/cyclones.js';
const NOW = Date.UTC(2026, 8, 16, 3, 35);
const TIME = '2026-09-16T03:00:00.000Z';
function status(overrides = {}) {
return {
activeStorms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
intensity: '25',
pressure: '1006',
longitudeNumeric: -125.8,
latitudeNumeric: 15.5,
movementDir: 255,
movementSpeed: 12,
lastUpdate: TIME,
forecastAdvisory: {
advNum: '010',
issuance: TIME,
url: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
},
...overrides,
},
],
};
}
function collections(adv = '10') {
const geometry = [
{ type: 'Point', coordinates: [-125.8, 15.5] },
{
type: 'LineString',
coordinates: [
[-125.8, 15.5],
[-128, 15],
],
},
{
type: 'Polygon',
coordinates: [
[
[-126, 15],
[-128, 15],
[-127, 17],
[-126, 15],
],
],
},
];
return geometry.map((shape, index) => ({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
properties: {
idp_source: `ep152026-${adv.padStart(3, '0')}_5day_${['pts', 'lin', 'pgn'][index]}`,
advisnum: adv,
tau: 0,
maxwind: 25,
gust: 9999,
},
geometry: shape,
},
],
}));
}
function install(options = {}, preview = false) {
let handler;
const plugin = cycloneProxy({ now: () => NOW, ...options });
plugin[preview ? 'configurePreviewServer' : 'configureServer']({
middlewares: {
use(path, callback) {
assert.equal(path, '/api/cyclones');
handler = callback;
},
},
});
const begin = (url = '/', method = 'GET') => {
const res = new EventEmitter();
res.writeHead = (code, headers) => {
res.statusCode = code;
res.headers = headers;
};
res.end = (body) => {
res.body = body;
};
return { res, done: handler({ url, method }, res).then(() => res) };
};
return { begin, request: (url, method) => begin(url, method).done };
}
const body = (res) => JSON.parse(res.body);
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
function fetcher(calls = [], data = status(), parts = collections()) {
return async (url, options) => {
calls.push({ url: new URL(url), options });
return Response.json(
url.includes('CurrentStorms')
? data
: parts[Number(url.match(/\/(\d)\/query/)[1]) - 5],
);
};
}
test('status retains official clocks, knots, source links and finite current position', () => {
const storm = parseCycloneStatus(status(), NOW)[0];
assert.equal(storm.advisoryNumber, '10');
assert.equal(storm.issuedAt, TIME);
assert.equal(storm.positionAt, TIME);
assert.deepEqual(storm.position, { longitude: -125.8, latitude: 15.5 });
assert.deepEqual(storm.movement, { directionDegrees: 255, speedKt: 12 });
assert.equal(storm.windKt, 25);
assert.equal(storm.pressureHpa, 1006);
assert.match(storm.advisoryUrl, /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
const missing = parseCycloneStatus(
status({ intensity: '9999', pressure: null, movementSpeed: '' }),
NOW,
)[0];
assert.equal(missing.windKt, null);
assert.equal(missing.pressureHpa, null);
assert.equal(missing.movement.speedKt, null);
assert.equal(
parseCycloneStatus(
status({
forecastAdvisory: {
advNum: '10',
issuance: TIME,
url: 'https://www.nhc.noaa.gov.evil/secret',
},
}),
NOW,
)[0].advisoryUrl,
null,
);
});
test('malformed, duplicate, oversized, future and expired status is rejected rather than reported empty', () => {
for (const payload of [
{},
{ activeStorms: null },
{ activeStorms: Array(33).fill(status().activeStorms[0]) },
{ activeStorms: [...status().activeStorms, ...status().activeStorms] },
status({ id: 'wp012026' }),
status({ name: 'a'.repeat(81) }),
status({ name: '<script>' }),
status({ longitudeNumeric: Infinity }),
status({ latitudeNumeric: 91 }),
status({ lastUpdate: '2026-09-17T00:00:00.000Z' }),
status({ lastUpdate: '2026-02-30T00:00:00.000Z' }),
status({
forecastAdvisory: { advNum: '10', issuance: '2026-09-15T03:00:00.000Z' },
}),
])
assert.throws(() => parseCycloneStatus(payload, NOW));
assert.deepEqual(parseCycloneStatus({ activeStorms: [] }, NOW), []);
});
test('coherent geometry is retained with holes/dateline while older advisory geometry is omitted', () => {
const storms = parseCycloneStatus(status(), NOW);
const parts = collections();
parts[1].features[0].geometry.coordinates = [
[179, 15],
[-179, 16],
];
parts[2].features[0].geometry.coordinates.push([
[-126.5, 15.5],
[-127, 15.5],
[-126.8, 16],
[-126.5, 15.5],
]);
const good = attachCycloneGeometry(storms, parts)[0];
assert.equal(good.geometryStatus, 'current');
assert.equal(good.geometryAdvisoryNumber, '10');
assert.deepEqual(good.track, parts[1].features[0].geometry);
assert.equal(good.cone.coordinates.length, 2);
assert.equal(good.forecastPoints[0].gustKt, null);
assert.equal(good.forecastPoints[0].tauHours, 0);
assert.equal(
'validAt' in good.forecastPoints[0],
false,
'do not fabricate point time from advisory + tau',
);
const old = attachCycloneGeometry(storms, collections('9'))[0];
assert.equal(old.geometryStatus, 'pending');
assert.equal(old.track, null);
assert.equal(old.cone, null);
assert.deepEqual(old.forecastPoints, []);
assert.deepEqual(old.position, storms[0].position);
const mixed = collections();
mixed[2] = collections('9')[2];
const partial = attachCycloneGeometry(storms, mixed)[0];
assert.equal(partial.geometryStatus, 'pending');
assert.equal(
partial.track,
null,
'a new track and old cone cannot form one advisory snapshot',
);
});
test('geometry rejects transfer truncation, duplicate tracks, invalid coordinates and structural budgets', () => {
const storms = parseCycloneStatus(status(), NOW);
const mutations = [
(p) => {
p[0].exceededTransferLimit = true;
},
(p) => {
p[0].features = Array(501).fill(p[0].features[0]);
},
(p) => {
p[1].features.push(p[1].features[0]);
},
(p) => {
p[0].features.push(p[0].features[0]);
},
(p) => {
p[0].features[0].properties.idp_source = 'ep152026-009_5day_pts';
},
(p) => {
p[1].features[0].geometry.coordinates[1] = [181, 1];
},
(p) => {
p[2].features[0].geometry.coordinates[0].pop();
},
(p) => {
p[1].features[0].geometry.coordinates = Array(10001).fill([0, 0]);
},
(p) => {
p[1].features[0].geometry = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () => Array(9000).fill([0, 0])),
};
},
(p) => {
p[2].features[0].geometry = {
type: 'GeometryCollection',
geometries: [],
};
},
];
for (const mutate of mutations) {
const parts = collections();
mutate(parts);
assert.throws(() => attachCycloneGeometry(storms, parts));
}
});
test('dev and preview serve only fixed bounded NOAA queries, caching a complete snapshot', async () => {
for (const preview of [false, true]) {
const calls = [];
const { request } = install({ fetchImpl: fetcher(calls) }, preview);
const snapshot = body(await request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.stale, false);
assert.match(snapshot.coverage, /not worldwide/);
assert.equal(snapshot.storms[0].geometryStatus, 'current');
assert.equal(snapshot.fetchedAt, NOW);
assert.equal(calls.length, 4);
assert.equal(
calls[0].url.href,
'https://www.nhc.noaa.gov/CurrentStorms.json',
);
for (const call of calls) assert.equal(call.options.redirect, 'error');
for (const call of calls.slice(1)) {
assert.equal(call.url.origin, 'https://mapservices.weather.noaa.gov');
assert.equal(call.url.searchParams.get('outSR'), '4326');
assert.equal(call.url.searchParams.get('f'), 'geojson');
assert.notEqual(call.url.searchParams.get('outFields'), '*');
}
assert.equal(
calls[2].url.searchParams.get('outFields'),
'idp_source,advisnum',
);
assert.deepEqual(body(await request()), snapshot);
assert.equal(calls.length, 4);
}
});
test('query/method rejection cannot redirect or widen provider work', async () => {
let calls = 0;
const { request } = install({
fetchImpl: async () => {
calls++;
throw new Error('unexpected');
},
});
for (const url of [
'/?url=https://evil',
'/?storm=ep152026',
'/other',
'/?bbox=0,0,1,1',
])
assert.equal((await request(url)).statusCode, 400);
assert.equal((await request('/', 'POST')).statusCode, 405);
assert.equal(calls, 0);
});
test('confirmed empty status skips GIS; broken GIS keeps honest current positions', async () => {
const calls = [];
const empty = install({ fetchImpl: fetcher(calls, { activeStorms: [] }) });
assert.deepEqual(body(await empty.request()).storms, []);
assert.equal(body(await empty.request()).unavailable, false);
assert.equal(calls.length, 1);
const broken = install({
fetchImpl: async (url) =>
url.includes('CurrentStorms')
? Response.json(status())
: new Response('secret detail', { status: 503 }),
});
const snapshot = body(await broken.request());
assert.equal(snapshot.unavailable, false);
assert.equal(snapshot.storms[0].geometryStatus, 'unavailable');
assert.equal(snapshot.storms[0].track, null);
assert.doesNotMatch(JSON.stringify(snapshot), /secret/);
});
test('five-minute TTL, failure cooldown and age-limited last-good retain original advisory/fetch clocks', async () => {
let clock = NOW;
let fail = false;
let calls = 0;
const good = fetcher();
const { request } = install({
now: () => clock,
fetchImpl: async (...args) => {
calls++;
if (fail) throw new Error('private');
return good(...args);
},
});
await request();
clock += 299_000;
await request();
assert.equal(calls, 4);
fail = true;
clock += 2000;
const stale = body(await request());
assert.equal(stale.stale, true);
assert.equal(stale.fetchedAt, NOW);
assert.equal(stale.storms[0].issuedAt, TIME);
await request();
assert.equal(calls, 5);
clock += 12 * 3600_000;
const expired = body(await request());
assert.equal(expired.unavailable, true);
assert.deepEqual(expired.storms, []);
});
test('shared refresh survives one disconnect and aborts when its last consumer leaves', async () => {
let release;
let signal;
const good = fetcher();
const { begin } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms')) {
signal = options.signal;
await new Promise((resolve, reject) => {
release = resolve;
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
});
});
}
return good(url, options);
},
});
const a = begin();
const b = begin();
await nextTurn();
a.res.emit('close');
await a.done;
assert.equal(signal.aborted, false);
assert.equal(a.res.body, undefined);
release();
assert.equal(body(await b.done).unavailable, false);
let calls = 0;
const cancel = install({
fetchImpl: async (url, options) => {
calls++;
if (calls > 1) return good(url, options);
signal = options.signal;
return new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(new Error('aborted')), {
once: true,
}),
);
},
});
const c = cancel.begin();
await nextTurn();
c.res.emit('close');
await c.done;
assert.equal(signal.aborted, true);
assert.equal(
body(await cancel.request()).unavailable,
false,
'cancellation does not impose upstream failure cooldown',
);
});
test('stream caps and whole refresh deadline cancel oversized or stalled response bodies', async () => {
for (const stalled of [false, true]) {
let cancelled = false;
const { request } = install({
timeoutMs: 15,
fetchImpl: async () =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(stalled ? 4 : 128 * 1024 + 1));
},
cancel() {
cancelled = true;
},
}),
),
});
const snapshot = body(await request());
assert.equal(snapshot.unavailable, true);
assert.equal(cancelled, true);
}
});
test('shared refresh caps waiting consumers and rejects redirects without exposing upstream errors', async () => {
let release;
const good = fetcher();
const { request } = install({
fetchImpl: async (url, options) => {
if (url.includes('CurrentStorms'))
await new Promise((resolve) => {
release = resolve;
});
return good(url, options);
},
});
const waiting = Array.from({ length: 32 }, () => request());
await nextTurn();
assert.equal((await request()).statusCode, 429);
release();
assert.ok((await Promise.all(waiting)).every((r) => r.statusCode === 200));
const redirected = install({
fetchImpl: async () =>
new Response('private', {
status: 302,
headers: { Location: 'http://127.0.0.1/' },
}),
});
assert.equal(body(await redirected.request()).unavailable, true);
});
+52
View File
@@ -42,6 +42,17 @@ export const DATA_CREDITS = [
'<a href="https://adsb.lol" target="_blank" rel="noopener">adsb.lol</a> ' +
'(ODbL 1.0)',
},
{
key: 'adsbdb',
html:
'Aircraft type, registration &amp; flight routes: ' +
'<a href="https://www.adsbdb.com" target="_blank" rel="noopener">adsbdb</a> ' +
'· aircraft data from PlaneBase · ICAO-to-N-number conversion by ' +
'Guillaume Michel · route data is the work of ' +
'David Taylor, Edinburgh, and Jim Mason, Glasgow, and may not be ' +
'copied, published, or incorporated into other databases without the ' +
'explicit permission of David J Taylor, Edinburgh',
},
{
key: 'aisstream',
html:
@@ -66,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:
@@ -136,6 +156,10 @@ export const DATA_CREDITS = [
'CCTV cameras &amp; frames (Texas): ' +
'<a href="https://its.txdot.gov/" target="_blank" rel="noopener">Texas Department of Transportation</a> (courtesy)',
},
{
key: 'deldot-cctv',
html: 'CCTV live video (Delaware): <a href="https://deldot.gov/map/" target="_blank" rel="noopener">DelDOT — Delaware Department of Transportation</a>',
},
{
key: 'caltrans-cctv',
html:
@@ -201,6 +225,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',
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
assert.equal(
new Set(keys).size,
keys.length,
'a duplicate key would silently shadow one provider’s credit',
);
for (const entry of DATA_CREDITS) {
assert.ok(entry.key, 'a credit without a key cannot be registered');
assert.ok(
entry.html && entry.html.trim().length > 0,
`credit ${entry.key} has nothing to show`,
);
}
});
test('adsbdb is credited and carries its published route-data restriction', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'adsbdb');
assert.ok(
credit,
'adsbdb supplies aircraft type and routes and must be credited',
);
// adsbdb publishes this restriction for its route data. Pin the provider's
// credits and restriction here so a later edit cannot silently remove them.
assert.match(credit.html, /David Taylor, Edinburgh/);
assert.match(credit.html, /Jim Mason, Glasgow/);
assert.match(
credit.html,
/may not be\s+copied, published, or incorporated into other databases/,
);
assert.match(credit.html, /explicit permission of David J Taylor, Edinburgh/);
assert.match(credit.html, /PlaneBase/);
assert.match(credit.html, /Guillaume Michel/);
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
});
+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}
+8
View File
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from './issPass.js';
import { findNextSatellitePass } from './satellitePass.js';
// Canonical archived ISS TLE (valid checksums; epoch 2008-09-20 ~12:25 UTC).
const L1 = '1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
@@ -27,6 +28,13 @@ test('finds a structurally-consistent ISS pass within 24h of the TLE epoch', ()
assert.ok(before && before.elevDeg < 10);
});
test('forwards its original 30 s coarse step and fineStepSec to the pass engine', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10, fineStepSec: 30 };
assert.deepEqual(findNextIssPass(opts), findNextSatellitePass({ ...opts, coarseStepSec: 30 }));
assert.notDeepEqual(findNextIssPass(opts), findNextIssPass({ ...opts, fineStepSec: 5 }));
});
test('returns null when no pass clears an absurd threshold', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextIssPass({ satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 89.9, horizonHours: 2 });
+253
View File
@@ -0,0 +1,253 @@
import { layerFeedState } from './feedState.js';
/**
* Shared layer snapshot envelope for voice and HUD answers.
*
* Data Layers chips, `analyst_query`, `get_current_view_state`, and the HUD
* summary must all name the same feed-state. This module is the one envelope:
* it classifies `getStats()` through `layerFeedState` and stamps the result
* so a stale or degraded feed cannot be narrated as live. Cesium-free so the
* HUD summary proxy can import it from Node.
*
* @module data/layerSnapshot
*/
/** Feed states the Data Layers chips and voice/HUD provenance share. */
export const LAYER_FEED_STATES = Object.freeze([
'nominal',
'loading',
'degraded',
'stale',
'fallback',
'unavailable',
]);
/** Chip labels for enabled layers. Disabled layers read OFF, not a feed-state. */
export const LAYER_FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
loading: 'LOADING',
degraded: 'DEGRADED',
stale: 'STALE',
fallback: 'FALLBACK',
unavailable: 'UNAVAILABLE',
});
/** Snapshot feed-state including a disabled layer. */
export const SNAPSHOT_FEED_STATES = Object.freeze([
...LAYER_FEED_STATES,
'off',
]);
/**
* Lower rank is more severe. Voice/HUD `overall` is the worst queried state
* so a mixed roster cannot hide an unavailable or stale feed behind a nominal
* neighbour.
*/
export const FEED_STATE_SEVERITY = Object.freeze({
unavailable: 0,
loading: 1,
degraded: 2,
stale: 3,
fallback: 4,
nominal: 5,
off: 6,
});
export { layerFeedState } from './feedState.js';
function snapshotError(stats) {
const err = stats?.error || stats?.lastError || stats?.managerRefreshError;
if (err == null || err === '') return null;
return err instanceof Error ? err.message : String(err);
}
/**
* Age of a lastUpdate timestamp in whole seconds.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {number|null} Non-negative seconds, or null when unknown.
*/
export function snapshotAgeSec(lastUpdate, now = Date.now()) {
const stamp = Number(lastUpdate);
if (!Number.isFinite(stamp) || stamp <= 0) return null;
return Math.max(0, Math.floor((now - stamp) / 1000));
}
/**
* Compact age label matching the Data Layers meta line.
* @param {number|null|undefined} lastUpdate Epoch ms.
* @param {number} [now=Date.now()] Clock for tests.
* @returns {string|null} `just now` / `12s ago` / `4m ago` / `2h ago`.
*/
export function snapshotAgeLabel(lastUpdate, now = Date.now()) {
const sec = snapshotAgeSec(lastUpdate, now);
if (sec === null) return null;
if (sec < 5) return 'just now';
if (sec < 60) return `${sec}s ago`;
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
return `${Math.floor(sec / 3600)}h ago`;
}
/**
* Pick the most severe feed-state from a list.
* @param {Iterable<string|null|undefined>} states Feed-state strings.
* @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'|'off'|null}
*/
export function worstFeedState(states) {
let worst = null;
let worstRank = Number.POSITIVE_INFINITY;
for (const state of states || []) {
if (!Object.hasOwn(FEED_STATE_SEVERITY, state)) continue;
const rank = FEED_STATE_SEVERITY[state];
if (rank < worstRank) {
worst = state;
worstRank = rank;
}
}
return worst;
}
/**
* One honest layer snapshot for voice/HUD narration.
*
* Disabled layers report `feedState: 'off'` — the same reading the chips use —
* even if leftover stats would otherwise classify as stale. Counts and errors
* still travel so a disabled layer with a last-known error is inspectable.
*
* @param {object} [layer] `DataLayerManager.getAll()` projection, or a layer-like
* object with `id`, `enabled`, `stats`, and optional `source`/`name`.
* @param {{ now?: number }} [options]
* @returns {{
* id: string|null,
* name: string|null,
* enabled: boolean,
* feedState: string,
* source: string|null,
* count: number,
* lastUpdate: number|null,
* ageSec: number|null,
* ageLabel: string|null,
* error: string|null,
* }}
*/
export function layerSnapshot(layer = {}, { now = Date.now() } = {}) {
const stats = (layer && typeof layer.stats === 'object' && layer.stats) || {};
const source = String(stats.source || layer.source || '').trim() || null;
const enabled = Boolean(layer.enabled);
const countRaw = Number(stats.count);
const count = Number.isFinite(countRaw) ? countRaw : 0;
const lastUpdate = Number.isFinite(Number(stats.lastUpdate))
? Number(stats.lastUpdate)
: null;
const feedState = enabled ? layerFeedState({ ...stats, source }) : 'off';
return {
id: layer.id || layer.layerKey || null,
name: layer.name || layer.label || layer.id || layer.layerKey || null,
enabled,
feedState,
source,
count,
lastUpdate,
ageSec: snapshotAgeSec(lastUpdate, now),
ageLabel: snapshotAgeLabel(lastUpdate, now),
error: snapshotError(stats),
};
}
/**
* Snapshot every layer in a roster.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {Array<object>}
*/
export function layerSnapshots(layers = [], options) {
if (!Array.isArray(layers)) return [];
return layers.map((layer) => layerSnapshot(layer, options));
}
function asSnapshot(entry, now) {
if (!entry || typeof entry !== 'object') return layerSnapshot({}, { now });
if (typeof entry.feedState === 'string') return entry;
return layerSnapshot(entry, { now });
}
/**
* Mechanical narration note so the model does not have to infer honesty.
* @param {Array<object>} layers Snapshots.
* @param {string|null} overall Worst considered feed-state.
* @returns {string}
*/
export function feedProvenanceNote(layers = [], overall = null) {
if (!overall) {
return 'No layer snapshot is attached. Do not invent a feed-state or a count.';
}
if (overall === 'nominal') {
return 'Queried enabled layers are nominal. Still name the count scope; do not claim whole-world coverage.';
}
if (overall === 'off') {
const names =
layers
.map((s) => s.name || s.id)
.filter(Boolean)
.join(', ') || 'the requested layer';
return `${names} is off. Do not invent a count; say the layer is off and offer to enable it.`;
}
const flagged = layers.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
);
const bits = flagged.map((s) => {
const extras = [
s.source,
s.ageLabel || snapshotAgeLabel(s.lastUpdate),
s.error,
]
.filter(Boolean)
.join(', ');
return `${s.name || s.id} is ${String(s.feedState).toUpperCase()}${extras ? ` (${extras})` : ''}`;
});
const body = bits.join('; ') || `${String(overall).toUpperCase()} feed`;
return `${body}. Narrate that provenance with any count; do not describe this as live.`;
}
/**
* Shared envelope stamped onto analyst, view-state, and HUD payloads.
* @param {Array<object>} [snapshots] Snapshots or raw `getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{ overall: string|null, layers: Array<object>, note: string }}
*/
export function feedProvenanceEnvelope(
snapshots = [],
{ now = Date.now() } = {},
) {
const layers = (Array.isArray(snapshots) ? snapshots : []).map((entry) =>
asSnapshot(entry, now),
);
const active = layers.filter((s) => s.feedState && s.feedState !== 'off');
const overall =
worstFeedState(active.map((s) => s.feedState)) ||
(layers.length ? 'off' : null);
return {
overall,
layers,
note: feedProvenanceNote(layers, overall),
};
}
/**
* Compact voice-tool layer row: identity + the fields a model must read.
* @param {object} layer Raw `getAll()` row or snapshot.
* @param {{ now?: number }} [options]
* @returns {object}
*/
export function viewStateLayerRow(layer, options) {
const snap = layerSnapshot(layer, options);
return {
id: snap.id,
name: snap.name,
enabled: snap.enabled,
count: snap.count,
error: snap.error,
feedState: snap.feedState,
source: snap.source,
lastUpdate: snap.lastUpdate,
};
}
+260
View File
@@ -0,0 +1,260 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
FEED_STATE_SEVERITY,
LAYER_FEED_STATE_LABELS,
LAYER_FEED_STATES,
SNAPSHOT_FEED_STATES,
feedProvenanceEnvelope,
feedProvenanceNote,
layerFeedState,
layerSnapshot,
layerSnapshots,
snapshotAgeLabel,
snapshotAgeSec,
viewStateLayerRow,
worstFeedState,
} from './layerSnapshot.js';
const NOW = 1_700_000_000_000;
test('layerSnapshot stays Cesium-free so the HUD proxy can import it', () => {
const src = readFileSync(
new URL('./layerSnapshot.js', import.meta.url),
'utf8',
);
assert.equal(src.includes("from './manager.js'"), false);
assert.doesNotMatch(src, /from ['"]cesium['"]/i);
});
test('layerFeedState matches the chip classifier for every honesty class', () => {
assert.equal(
layerFeedState({ error: 'feed down', count: 0, lastUpdate: null }),
'unavailable',
);
assert.equal(
layerFeedState({
status: 'unavailable',
error: 'feed down',
count: 50,
lastUpdate: 1,
}),
'unavailable',
);
assert.equal(
layerFeedState({ mode: 'sim', count: 100, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({ source: 'adsb.lol', count: 10, lastUpdate: 1 }),
'fallback',
);
assert.equal(
layerFeedState({
source: 'adsb.lol',
fallback: false,
count: 10,
lastUpdate: 1,
}),
'nominal',
);
assert.equal(
layerFeedState({ stale: true, count: 0, lastUpdate: 1 }),
'stale',
);
assert.equal(
layerFeedState({
error: 'partial group failure',
count: 50,
lastUpdate: 1,
}),
'degraded',
);
assert.equal(layerFeedState({ loading: true }), 'loading');
assert.equal(layerFeedState({ count: 5, lastUpdate: 1 }), 'nominal');
assert.equal(
layerFeedState({
status: 'zoom-in',
error: 'zoom in to search',
count: 12,
}),
'nominal',
);
assert.equal(
layerFeedState({ status: 'zoom-in', stale: true, count: 12 }),
'stale',
);
});
test('chip labels and severity cover every feed-state the envelope can emit', () => {
assert.deepEqual(
[...LAYER_FEED_STATES],
['nominal', 'loading', 'degraded', 'stale', 'fallback', 'unavailable'],
);
assert.deepEqual([...SNAPSHOT_FEED_STATES], [...LAYER_FEED_STATES, 'off']);
for (const state of LAYER_FEED_STATES) {
assert.equal(typeof LAYER_FEED_STATE_LABELS[state], 'string');
assert.equal(typeof FEED_STATE_SEVERITY[state], 'number');
}
assert.equal(
FEED_STATE_SEVERITY.unavailable < FEED_STATE_SEVERITY.stale,
true,
);
assert.equal(FEED_STATE_SEVERITY.stale < FEED_STATE_SEVERITY.fallback, true);
assert.equal(worstFeedState(['nominal', 'stale', 'fallback']), 'stale');
assert.equal(worstFeedState(['degraded', 'unavailable']), 'unavailable');
assert.equal(worstFeedState([]), null);
});
test('a disabled layer is off even when leftover stats would read stale', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: false,
source: 'OpenSky Network',
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'off');
assert.equal(snap.enabled, false);
assert.equal(snap.count, 12);
assert.equal(snap.ageLabel, '4m ago');
});
test('an enabled stale layer carries age, source, and a mechanical note', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
stale: true,
count: 12,
lastUpdate: NOW - 240_000,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'stale');
assert.equal(snap.source, 'OpenSky Network');
assert.equal(snap.ageSec, 240);
assert.equal(snap.ageLabel, '4m ago');
const envelope = feedProvenanceEnvelope([snap], { now: NOW });
assert.equal(envelope.overall, 'stale');
assert.match(envelope.note, /Live Flights is STALE/);
assert.match(envelope.note, /OpenSky Network/);
assert.match(envelope.note, /4m ago/);
assert.match(envelope.note, /do not describe this as live/i);
});
test('degraded and unavailable envelopes outrank leftover nominal neighbours', () => {
const layers = layerSnapshots(
[
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: true,
stats: { count: 3, lastUpdate: NOW - 10_000 },
},
{
id: 'local-firms',
name: 'Active Fires',
enabled: true,
stats: {
error: 'FIRMS key missing',
count: 8,
lastUpdate: NOW - 30_000,
},
},
],
{ now: NOW },
);
const envelope = feedProvenanceEnvelope(layers, { now: NOW });
assert.equal(envelope.overall, 'degraded');
assert.match(envelope.note, /Active Fires is DEGRADED/);
assert.match(envelope.note, /FIRMS key missing/);
});
test('an unavailable feed with no prior data does not become a confident zero', () => {
const snap = layerSnapshot(
{
id: 'ais-live-vessels',
name: 'Live Vessels',
enabled: true,
stats: { error: 'AISStream down', count: 0, lastUpdate: null },
},
{ now: NOW },
);
assert.equal(snap.feedState, 'unavailable');
const envelope = feedProvenanceEnvelope([snap]);
assert.equal(envelope.overall, 'unavailable');
assert.match(envelope.note, /UNAVAILABLE/);
});
test('fallback provenance names the fallback source instead of calling it live', () => {
const snap = layerSnapshot(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
source: 'adsb.lol fallback',
count: 40,
lastUpdate: NOW - 8_000,
},
},
{ now: NOW },
);
assert.equal(snap.feedState, 'fallback');
assert.match(feedProvenanceNote([snap], 'fallback'), /adsb\.lol fallback/);
});
test('view-state rows keep the existing identity fields and add feedState', () => {
const row = viewStateLayerRow(
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: {
count: 9,
lastUpdate: 42,
error: null,
source: 'OpenSky Network',
},
},
{ now: NOW },
);
assert.deepEqual(row, {
id: 'flights',
name: 'Live Flights',
enabled: true,
count: 9,
error: null,
feedState: 'nominal',
source: 'OpenSky Network',
lastUpdate: 42,
});
});
test('empty roster envelope does not pretend to be nominal', () => {
const envelope = feedProvenanceEnvelope([]);
assert.equal(envelope.overall, null);
assert.match(envelope.note, /Do not invent/);
});
test('age labels match the Data Layers meta clock', () => {
assert.equal(snapshotAgeSec(null, NOW), null);
assert.equal(snapshotAgeLabel(NOW - 3_000, NOW), 'just now');
assert.equal(snapshotAgeLabel(NOW - 12_000, NOW), '12s ago');
assert.equal(snapshotAgeLabel(NOW - 240_000, NOW), '4m ago');
assert.equal(snapshotAgeLabel(NOW - 7_200_000, NOW), '2h ago');
});
+203 -4
View File
@@ -168,7 +168,14 @@ function stringOption(key, token, defaultValue) {
});
}
function enumOption(key, token, defaultValue, values, codes) {
function enumOption(
key,
token,
defaultValue,
values,
codes,
{ absentValue = defaultValue } = {},
) {
const reverse = Object.fromEntries(
Object.entries(codes).map(([name, code]) => [code, name]),
);
@@ -176,6 +183,7 @@ function enumOption(key, token, defaultValue, values, codes) {
key,
token,
defaultValue,
absentValue,
normalize: (value) => normalizeEnum(values, value),
encode: (value) => codes[value],
decode: (value) => reverse[value] || null,
@@ -204,7 +212,131 @@ function integerOption(key, token, defaultValue) {
});
}
/**
* A share-link-only option: the whole-state codec carries it, but the stored
* local blob always holds its default.
*/
function shareOnlyOption(spec) {
return Object.freeze({ ...spec, shareOnly: true });
}
/**
* A signed integer in `[min, max]`, rejected (never clamped) outside it: a
* clamped box edge would be a different box, not a shorter spelling.
*/
function boundedIntegerOption(key, token, defaultValue, { min, max }) {
const parse = (value) => {
const number =
typeof value === 'number'
? value
: /^-?\d{1,9}$/.test(String(value).trim())
? Number(String(value).trim())
: NaN;
return Number.isInteger(number) && number >= min && number <= max
? number
: null;
};
return Object.freeze({
key,
token,
defaultValue,
normalize: parse,
encode: (value) => String(value),
decode: parse,
});
}
/**
* Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`.
* The calendar is checked both ways, and "today" is never consulted, so a
* link decodes the same whenever it is opened.
*/
const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' });
const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' });
function imageryKey(product, year, month, day) {
const time = Date.UTC(Number(year), Number(month) - 1, Number(day));
const iso = Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: '';
return product && iso === `${year}-${month}-${day}`
? `${product}:${iso}`
: null;
}
function imageryPinOption(key, token) {
return Object.freeze({
key,
token,
defaultValue: null,
normalize: (value) => {
const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec(
typeof value === 'string' ? value.trim() : '',
);
return match ? imageryKey(match[1], match[2], match[3], match[4]) : null;
},
encode: (value) => {
const [product, day] = value.split(':');
return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`;
},
decode: (value) => {
const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec(
typeof value === 'string' ? value : '',
);
return match
? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4])
: null;
},
});
}
const OPTION_GROUPS = Object.freeze({
'weather-lightning': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-radar': Object.freeze([
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
]),
'weather-satellite': Object.freeze([
enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], {
filtered: 'f',
full: 'a',
}),
enumOption('opacity', 'o', 'strong', ['light', 'strong'], {
light: 'l',
strong: 's',
}),
enumOption(
'product',
'p',
'clouds-regional',
['clouds', 'clouds-regional'],
{ clouds: 'g', 'clouds-regional': 'r' },
),
]),
wind: Object.freeze([
enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }),
enumOption(
'overlay',
'o',
'none',
['none', 'speed', 'temperature', 'pressure'],
{ none: 'n', speed: 's', temperature: 't', pressure: 'p' },
{ absentValue: 'speed' },
),
enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], {
'km/h': 'k',
'm/s': 'm',
mph: 'i',
}),
booleanOption('paused', 'p', false),
]),
flights: Object.freeze([
// Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in
// PROXIMITY mode. Proximity is itself the altitude/count gate — models only
@@ -249,6 +381,23 @@ const OPTION_GROUPS = Object.freeze({
booleanOption('showProjection', 'p', true),
booleanOption('autoHop', 'a', false),
]),
'recent-imagery': Object.freeze([
// Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit.
boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }),
boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }),
boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }),
imageryPinOption('a', 'a'),
imageryPinOption('b', 'b'),
// 0 one image, 1 image against the basemap, 2 two images A / B.
boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }),
// The swipe position is one comparison's framing, not a preference: a
// stored split resurfaced in the next session's first comparison.
shareOnlyOption(
boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }),
),
booleanOption('viirs', 'v', false),
]),
radio: Object.freeze([
Object.freeze({
key: 'filter',
@@ -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
View File
File diff suppressed because it is too large. Load diff
+222
View File
@@ -0,0 +1,222 @@
// Focused tests for the local-infrastructure analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createLocalGeoJsonLayer, mapAnalystRecord } from './localGeojson.js';
const DC_RAW = {
id: '1176042553',
lat: -52.942,
lon: -70.85,
properties: {
tags: {
name: 'AWS',
operator: 'Amazon Web Services',
'operator:short': 'AWS',
'capacity:it_load': '27 MW',
},
},
};
const DAM_RAW = {
id: '-19685440',
lat: -25.41,
lon: -54.59,
properties: {
name: 'Usina Hidrelétrica de Itaipu',
output: '14000 MW',
tags: {
name: 'Usina Hidrelétrica de Itaipu',
'name:en': 'Itaipu Dam',
operator: 'Itaipú Binacional',
associated_river: 'Paraná',
'plant:output:electricity': '14000 MW',
},
},
};
test('infra analyst record: datacenter maps name, operator, capacity', () => {
const r = mapAnalystRecord(DC_RAW, 'local-datacenters');
assert.deepEqual(r, {
id: 'AWS',
name: 'AWS',
lat: -52.942,
lon: -70.85,
operator: 'Amazon Web Services',
capacity: '27 MW',
river: null,
output: null,
});
});
test('infra analyst record: dam maps name, operator, river, output; names stay unclamped', () => {
const r = mapAnalystRecord(DAM_RAW, 'local-dams');
assert.equal(r.id, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.name, 'Usina Hidrelétrica de Itaipu');
assert.equal(r.operator, 'Itaipú Binacional');
assert.equal(r.river, 'Paraná');
assert.equal(r.output, '14000 MW');
assert.equal(r.capacity, null);
});
test('infra analyst record: unnamed feature falls back to source id', () => {
const r = mapAnalystRecord(
{
id: 'dc-42',
lat: 30.2,
lon: -97.7,
properties: { tags: { operator: 'Example Cloud' } },
},
'local-datacenters',
);
assert.equal(r.id, 'dc-42');
assert.equal(r.name, null);
assert.equal(r.operator, 'Example Cloud');
});
test('infra analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined, 'local-dams');
assert.equal(r.id, 'Dam');
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('infra analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord(
{
...DAM_RAW,
entity: {},
position: { x: 1 },
},
'local-dams',
);
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('entity' in r, false);
assert.equal('position' in r, false);
});
class MockLayerEvent {
constructor() {
this.listeners = new Set();
}
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('infra getAnalystRecords: enabled layer snapshots loaded stems; disable returns []', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
globalThis.window = { dispatchEvent() {} };
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () =>
JSON.stringify({
type: 'Feature',
id: 'runtime-dam',
properties: {
name: 'Runtime Dam',
output: '12 MW',
tags: { associated_river: 'Test River', operator: 'Test Hydro' },
},
geometry: {
type: 'Polygon',
coordinates: [
[
[-97.7, 30.2],
[-97.69, 30.2],
[-97.69, 30.21],
[-97.7, 30.2],
],
],
},
}),
});
const viewer = {
selectedEntity: undefined,
dataSources: {
add(dataSource) {
return dataSource;
},
remove() {
return true;
},
},
camera: {
positionWC: Cesium.Cartesian3.fromDegrees(-97.695, 30.205, 100_000),
frustum: { fov: Math.PI / 3 },
moveEnd: new MockLayerEvent(),
flyTo() {},
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender: new MockLayerEvent(),
sampleHeightSupported: false,
sampleHeight() {
return undefined;
},
screenSpaceCameraController: { enableInputs: true },
pick() {
return null;
},
requestRender() {},
},
};
const layer = createLocalGeoJsonLayer({
id: 'local-dams',
url: '/runtime-dam.geojsonl',
name: 'Runtime Dams',
color: '#0088ff',
overlayHost: { setVisible() {}, setEntries() {}, clearSource() {} },
projectToWindow: () => ({ x: 400, y: 300 }),
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
});
try {
assert.deepEqual(
layer.getAnalystRecords(),
[],
'unenabled layer has no analyst records',
);
await layer.enable(viewer);
const rows = layer.getAnalystRecords();
assert.equal(rows.length, 1);
assert.equal(rows[0].id, 'Runtime Dam');
assert.equal(rows[0].name, 'Runtime Dam');
assert.equal(rows[0].operator, 'Test Hydro');
assert.equal(rows[0].river, 'Test River');
assert.equal(rows[0].output, '12 MW');
assert.ok(Number.isFinite(rows[0].lat) && Number.isFinite(rows[0].lon));
assert.deepEqual(JSON.parse(JSON.stringify(rows[0])), rows[0]);
assert.equal(
layer.getAnalystRecords(0).length,
1,
'non-finite/zero cap still returns at least one',
);
layer.disable(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'disable keeps stems but analyst snapshot is empty',
);
} finally {
layer.destroy(viewer);
assert.deepEqual(
layer.getAnalystRecords(),
[],
'destroy releases analyst records',
);
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
}
});
+107 -1
View File
@@ -89,6 +89,78 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -310,7 +382,7 @@ export function localDatasetError(error) {
* standard scene.pick natively clicks them.
* @param {object} options Dataset URL, identity, appearance and optional Cesium adapters.
* @param {object} services Caller-owned operations; see docs/INFRASTRUCTURE-LAYERS.md.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats.
* @returns {object} A fresh layer implementing init/enable/disable/update/destroy/getStats/getAnalystRecords.
* One live instance per layer id is allowed in a given viewer/context/overlay host.
* Destroy the previous instance before replacing it. Importing creates no layers.
*/
@@ -530,6 +602,40 @@ export function createLocalGeoJsonLayer(
computed: _lodComputed,
}),
/**
* Snapshot in-memory infrastructure features as plain JSON-safe objects
* for the analyst query engine. On-demand only (called at most once per
* spoken query) — zero per-frame cost, no listeners, no caching. Returns
* [] while the layer is disabled or empty. Disable keeps the loaded
* stems for reuse; this method still returns [] until the next enable.
* @param {number} [maxCount=2000] Maximum records to return (truncation).
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!_enabled || !_stemRecords.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (let i = 0; i < _stemRecords.length; i++) {
if (result.length >= limit) break;
const record = _stemRecords[i];
const carto = record.carto;
result.push(
mapAnalystRecord(
{
id: record.id,
lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null,
lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null,
properties: propertyObject(record.entity),
},
id,
),
);
}
return result;
},
enable: async (viewer) => {
if (_destroyed) return;
_enabled = true;
+141
View File
@@ -2746,6 +2746,12 @@ function makeControlElement() {
classList: { toggle() {} },
appendChild(child) { child.parent = this; this.children.push(child); return child; },
append(...nodes) { for (const n of nodes) n.parent = this; this.children.push(...nodes); },
insertBefore(child, anchor) {
child.parent = this;
const index = anchor ? this.children.indexOf(anchor) : this.children.length;
this.children.splice(index, 0, child);
return child;
},
replaceChildren(...nodes) { this.children = [...nodes]; },
remove() {
const siblings = this.parent?.children;
@@ -3204,6 +3210,7 @@ test('an async layer pushes its own row refresh, and a busy chip refuses clicks'
// The layer settles and pushes its own refresh — no panel poll involved.
settled = true;
module._listener();
mgr._layerPanel._flushRowControlsRefresh();
assert.equal(chip.textContent, 'DENSE');
assert.equal(chip.disabled, false);
assert.equal(chip.attributes['aria-pressed'], 'true');
@@ -3336,3 +3343,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;
}
});
+458
View File
@@ -0,0 +1,458 @@
// src/data/satellitePass.js
/**
* Satellite pass prediction with naked-eye visibility.
* Look angles come from SGP4; rise/set are bisected to sub-second precision,
* the peak is refined with a parabola, and visibility uses the USNO
* low-precision Sun position with a cylindrical Earth shadow. Scan pattern from
* skylight (MIT) shared/src/celestial.ts nextISSPass.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
const EARTH_RADIUS_KM = 6371.0;
const AU_KM = 149597870.7;
/**
* Observer look angles at an instant, or null when propagation fails.
* @param {Object} satrec SGP4 satellite record
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @param {number} latDeg Observer latitude in degrees [-90, 90]
* @param {number} lonDeg Observer longitude in degrees [-180, 180]
* @returns {{ elevDeg: number, azDeg: number, satECI: {x:number, y:number, z:number} } | null}
*/
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
let pv;
try {
pv = propagate(satrec, date);
} catch {
return null;
}
const pos = pv && pv.position;
if (
!pos ||
typeof pos === 'boolean' ||
![pos.x, pos.y, pos.z].every(Number.isFinite)
)
return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
satECI: pos,
};
}
/**
* Compute Sun position in geocentric ECI coordinates using USNO low-precision solar coordinates.
* Accurate to within ~1 arcminute (~0.017°) within centuries of J2000.0.
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {{ x: number, y: number, z: number, raDeg: number, decDeg: number }}
*/
export function solarPositionECI(dateMs) {
const JD = dateMs / 86400000 + 2440587.5;
const D = JD - 2451545.0; // days since J2000.0
const g = ((((357.529 + 0.98560028 * D) % 360) + 360) % 360) * D2R;
const q = (((280.459 + 0.98564736 * D) % 360) + 360) % 360;
const L =
((((q + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) % 360) + 360) % 360) *
D2R;
const e = (23.439 - 0.00000036 * D) * D2R;
const ra = Math.atan2(Math.cos(e) * Math.sin(L), Math.cos(L));
const dec = Math.asin(Math.sin(e) * Math.sin(L));
return {
x: AU_KM * Math.cos(dec) * Math.cos(ra),
y: AU_KM * Math.cos(dec) * Math.sin(ra),
z: AU_KM * Math.sin(dec),
raDeg: (((ra * R2D) % 360) + 360) % 360,
decDeg: dec * R2D,
};
}
/**
* Check whether a satellite in ECI is illuminated by the Sun (outside Earth shadow).
* Uses a cylindrical Earth shadow model with mean radius 6,371 km.
* @param {{ x: number, y: number, z: number }} satECI Satellite position in km
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {boolean} True if sunlit, false if in eclipse (Earth shadow)
*/
export function isSatelliteSunlit(satECI, dateMs) {
if (!satECI || typeof satECI === 'boolean') return false;
const sun = solarPositionECI(dateMs);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
const proj = satECI.x * sx + satECI.y * sy + satECI.z * sz;
if (proj > 0) return true; // Sunward side of Earth
const dx = satECI.x - proj * sx;
const dy = satECI.y - proj * sy;
const dz = satECI.z - proj * sz;
return Math.hypot(dx, dy, dz) > EARTH_RADIUS_KM;
}
/**
* Compute the observer local solar elevation angle in degrees.
* @param {number} latDeg Observer latitude in degrees
* @param {number} lonDeg Observer longitude in degrees
* @param {number} dateMs UTC epoch timestamp in milliseconds
* @returns {number} Solar elevation in degrees [-90, 90]
*/
export function observerSolarElevation(latDeg, lonDeg, dateMs) {
const sun = solarPositionECI(dateMs);
const gmst = gstime(new Date(dateMs));
const gmstDeg = (((gmst * R2D) % 360) + 360) % 360;
const localHourAngleDeg =
(((gmstDeg + lonDeg - sun.raDeg) % 360) + 360) % 360;
const H = localHourAngleDeg * D2R;
const phi = latDeg * D2R;
const dec = sun.decDeg * D2R;
const sinElev =
Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H);
return Math.asin(Math.max(-1, Math.min(1, sinElev))) * R2D;
}
/**
* Check whether the observer sky is dark (civil, nautical, or astronomical twilight / night).
* Default threshold is -6° (civil twilight).
* @param {number} latDeg Observer latitude
* @param {number} lonDeg Observer longitude
* @param {number} dateMs UTC timestamp
* @param {number} [maxSunElevDeg=-6] Sun elevation ceiling (default -6° for civil twilight)
* @returns {boolean} True if sun is at or below the threshold
*/
export function isObserverDark(latDeg, lonDeg, dateMs, maxSunElevDeg = -6) {
return observerSolarElevation(latDeg, lonDeg, dateMs) <= maxSunElevDeg;
}
/**
* Bisect the elevation threshold boundary f(t) = elev(t) - minElevDeg = 0.
* Assumes one threshold crossing inside the bracket. 7 iterations resolve 20s to ~0.16s.
* @private
*/
function _bisectBoundary(
satrec,
latDeg,
lonDeg,
tLow,
tHigh,
minElevDeg,
isRising,
steps = 10,
) {
let low = tLow;
let high = tHigh;
for (let i = 0; i < steps; i++) {
const mid = (low + high) / 2;
const look = lookAnglesAt(satrec, mid, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (isRising) {
if (elev >= minElevDeg) {
high = mid;
} else {
low = mid;
}
} else {
if (elev >= minElevDeg) {
low = mid;
} else {
high = mid;
}
}
}
return isRising ? Math.ceil(high) : Math.floor(low);
}
/**
* Find the next pass of a satellite over an observer location.
* Uses 20s coarse scanning, bisection for sub-second rise/set times,
* parabolic interpolation for the peak, and optional naked-eye visibility filtering.
*
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude [-90, 90]
* @param {number} options.lonDeg Observer longitude [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set (default 10°)
* @param {number} [options.horizonHours=24] Maximum search horizon in hours (default 24h)
* @param {number} [options.coarseStepSec=20] Coarse scan step in seconds (default 20s)
* @param {number} [options.fineStepSec=5] Fine transit step in seconds (default 5s)
* @param {boolean} [options.requireVisible=false] Skip passes that are not naked-eye visible
* @param {number} [options.maxSunElevDeg=-6] Ceiling for observer darkness (default -6° for civil twilight)
* @returns {{
* riseMs: number,
* setMs: number,
* maxElevDeg: number,
* maxElevMs: number,
* riseAzDeg: number,
* visible: boolean,
* sunlit: boolean,
* observerDark: boolean
* } | null} `visible` is true when any sampled part of the pass is sunlit under a
* dark sky; `sunlit` and `observerDark` describe the peak only.
*/
export function findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg = 10,
horizonHours = 24,
coarseStepSec = 20,
fineStepSec = 5,
requireVisible = false,
maxSunElevDeg = -6,
}) {
if (
!satrec ||
![
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
maxSunElevDeg,
].every(Number.isFinite) ||
Math.abs(latDeg) > 90 ||
Math.abs(lonDeg) > 180 ||
Math.abs(fromMs) > 8.63e15 ||
minElevDeg < 0 ||
minElevDeg > 90 ||
horizonHours <= 0 ||
horizonHours > 72 ||
coarseStepSec < 1 ||
coarseStepSec > 120 ||
fineStepSec < 0.1 ||
fineStepSec > 60
)
return null;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarseMs = coarseStepSec * 1000;
const fineMs = Math.max(1000, (fineStepSec || 5) * 1000);
let searchCursor = fromMs;
while (searchCursor <= horizonMs) {
let tPrev = searchCursor;
let tHit = null;
let elevPrev = lookAnglesAt(satrec, tPrev, latDeg, lonDeg)?.elevDeg ?? -90;
// Check if we start already inside a pass
if (elevPrev >= minElevDeg) {
tHit = searchCursor;
} else {
// Coarse forward scan
for (let t = searchCursor + coarseMs; t <= horizonMs; t += coarseMs) {
const look = lookAnglesAt(satrec, t, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (elev >= minElevDeg) {
tHit = t;
break;
}
tPrev = t;
elevPrev = elev;
}
}
if (tHit == null) return null; // No pass found in remaining horizon
// Resolve rise
let riseMs;
if (tHit === searchCursor && elevPrev >= minElevDeg) {
riseMs = searchCursor;
} else {
riseMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tPrev,
tHit,
minElevDeg,
true,
);
}
// Transit the pass to track peak and bracket set between tStepPrev and tCurr
let tCurr = riseMs;
let maxElevDeg = -90;
let maxElevMs = riseMs;
let tStepPrev = riseMs;
let setMs = null;
let hasVisibleSegment = false;
let elevPrevSample = null;
let peakLeftElev = null;
let peakRightElev = null;
let peakStepMs = fineMs;
// Visibility at an instant inside [riseMs, setMs], so no elevation gate
const checkVisibility = (tMs) => {
if (hasVisibleSegment) return;
const look = lookAnglesAt(satrec, tMs, latDeg, lonDeg);
if (!look) return;
const sunlit = isSatelliteSunlit(look.satECI, tMs);
const dark = isObserverDark(latDeg, lonDeg, tMs, maxSunElevDeg);
if (sunlit && dark) {
hasVisibleSegment = true;
}
};
// Check visibility at rise
checkVisibility(riseMs);
// Transit forward in fine steps
while (tCurr <= horizonMs) {
const look = lookAnglesAt(satrec, tCurr, latDeg, lonDeg);
const elev = look ? look.elevDeg : -90;
if (look) {
const sunlit = isSatelliteSunlit(look.satECI, tCurr);
const dark = isObserverDark(latDeg, lonDeg, tCurr, maxSunElevDeg);
if (sunlit && dark && elev >= minElevDeg) {
hasVisibleSegment = true;
}
}
if (elev < minElevDeg && tCurr > riseMs) {
if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
// Below threshold: bisect set between tStepPrev and tCurr
setMs = _bisectBoundary(
satrec,
latDeg,
lonDeg,
tStepPrev,
tCurr,
minElevDeg,
false,
);
break;
}
if (elev > maxElevDeg) {
maxElevDeg = elev;
maxElevMs = tCurr;
peakLeftElev = elevPrevSample;
peakRightElev = null;
} else if (peakRightElev == null && tCurr > maxElevMs) {
peakRightElev = elev;
peakStepMs = tCurr - maxElevMs;
}
elevPrevSample = elev;
tStepPrev = tCurr;
tCurr += fineMs;
}
if (setMs == null) {
setMs = Math.min(horizonMs, tCurr);
}
// Refine peak culmination via 3-point parabolic interpolation around maxElevMs
// using adjacent transit samples (zero additional SGP4 calls)
let refinedPeakMs = maxElevMs;
let refinedPeakElevDeg = maxElevDeg;
if (
Number.isFinite(peakLeftElev) &&
Number.isFinite(peakRightElev) &&
peakLeftElev - 2 * maxElevDeg + peakRightElev < 0 // concave down
) {
const denom = 2 * (peakLeftElev - 2 * maxElevDeg + peakRightElev);
const shift = ((peakLeftElev - peakRightElev) / denom) * peakStepMs;
if (Math.abs(shift) < peakStepMs) {
refinedPeakMs = Math.max(
riseMs,
Math.min(setMs, Math.round(maxElevMs + shift)),
);
refinedPeakElevDeg = Math.min(
90,
maxElevDeg -
Math.pow(peakLeftElev - peakRightElev, 2) /
(8 * (peakLeftElev - 2 * maxElevDeg + peakRightElev)),
);
}
}
// Sun and sky state at the peak
const peakLook = lookAnglesAt(satrec, refinedPeakMs, latDeg, lonDeg);
const peakSunlit = peakLook
? isSatelliteSunlit(peakLook.satECI, refinedPeakMs)
: false;
const peakDark = isObserverDark(
latDeg,
lonDeg,
refinedPeakMs,
maxSunElevDeg,
);
// Check visibility at the refined peak and at set
checkVisibility(refinedPeakMs);
checkVisibility(setMs);
if (!hasVisibleSegment) {
hasVisibleSegment = hasVisibleInterval(riseMs, setMs, (time) => {
const look = lookAnglesAt(satrec, time, latDeg, lonDeg);
return [
Boolean(look && isSatelliteSunlit(look.satECI, time)),
isObserverDark(latDeg, lonDeg, time, maxSunElevDeg),
];
});
}
// Skip passes with no visible part when requireVisible is set
if (requireVisible && !hasVisibleSegment) {
searchCursor = Math.max(setMs + 1000, searchCursor + coarseMs);
continue;
}
const riseLook = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg: refinedPeakElevDeg,
maxElevMs: refinedPeakMs,
riseAzDeg: riseLook ? riseLook.azDeg : 0,
visible: hasVisibleSegment,
sunlit: peakSunlit,
observerDark: peakDark,
};
}
return null;
}
/** Resolve shadow/twilight crossings to 20ms so short overlap is not lost between samples. */
export function hasVisibleInterval(start, end, conditionsAt) {
const visible = (states) => states[0] && states[1];
for (let left = start; left < end; left += 5000) {
const right = Math.min(end, left + 5000);
const a = conditionsAt(left);
const b = conditionsAt(right);
if (visible(a) || visible(b)) return true;
const cuts = [left, right];
for (let condition = 0; condition < 2; condition++) {
if (a[condition] === b[condition]) continue;
let low = left;
let high = right;
while (high - low > 20) {
const mid = (low + high) / 2;
if (conditionsAt(mid)[condition] === a[condition]) low = mid;
else high = mid;
}
cuts.push((low + high) / 2);
}
cuts.sort((a, b) => a - b);
for (let i = 1; i < cuts.length; i++) {
if (visible(conditionsAt((cuts[i - 1] + cuts[i]) / 2))) return true;
}
}
return false;
}
+282
View File
@@ -0,0 +1,282 @@
// src/data/satellitePass.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec, propagate } from 'satellite.js';
import {
findNextSatellitePass,
hasVisibleInterval,
lookAnglesAt,
solarPositionECI,
isSatelliteSunlit,
observerSolarElevation,
isObserverDark,
} from './satellitePass.js';
// Canonical archived ISS TLE (epoch 2008-09-20 ~12:25 UTC).
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const AUSTIN = { latDeg: 30.2672, lonDeg: -97.7431 };
const FROM_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
test('solarPositionECI matches the Sun at J2000.0', () => {
// Reference: Cesium Simon1994PlanetaryPositions at 2000-01-01 12:00 UTC
// gives RA 281.289°, Dec -23.033°.
const j2000 = solarPositionECI(Date.UTC(2000, 0, 1, 12, 0, 0));
assert.ok(
Math.abs(j2000.raDeg - 281.289) < 0.05,
`RA ${j2000.raDeg}° within 0.05°`,
);
assert.ok(
Math.abs(j2000.decDeg - -23.033) < 0.05,
`Dec ${j2000.decDeg}° within 0.05°`,
);
});
test('solarPositionECI calculates reasonable solar coordinates across seasons', () => {
// Near Autumnal Equinox (Sept 22, 2020): declination ≈ 0°
const equinox = solarPositionECI(Date.UTC(2020, 8, 22, 12, 0, 0));
assert.ok(
Math.abs(equinox.decDeg) < 1.0,
'declination near equinox must be close to 0°',
);
// Near Summer Solstice (June 21, 2020): declination ≈ +23.44°
const solsticeSummer = solarPositionECI(Date.UTC(2020, 5, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeSummer.decDeg - 23.44) < 0.5,
'summer declination ≈ 23.44°',
);
// Near Winter Solstice (Dec 21, 2020): declination ≈ -23.44°
const solsticeWinter = solarPositionECI(Date.UTC(2020, 11, 21, 12, 0, 0));
assert.ok(
Math.abs(solsticeWinter.decDeg - -23.44) < 0.5,
'winter declination ≈ -23.44°',
);
});
test('observerSolarElevation distinguishes local noon from local midnight', () => {
// Greenwich noon on equinox: solar elevation should be close to 90 - lat
const greenwichNoon = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 12, 0, 0),
);
assert.ok(greenwichNoon > 30, 'sun must be well above horizon at local noon');
// Greenwich midnight: solar elevation must be deeply negative
const greenwichMidnight = observerSolarElevation(
51.5,
0,
Date.UTC(2020, 8, 22, 0, 0, 0),
);
assert.ok(
greenwichMidnight < -20,
'sun must be well below horizon at midnight',
);
assert.ok(isObserverDark(51.5, 0, Date.UTC(2020, 8, 22, 0, 0, 0), -6));
});
test('isSatelliteSunlit accurately computes cylindrical umbra shadow', () => {
const summerDate = Date.UTC(2020, 5, 21, 12, 0, 0);
const sun = solarPositionECI(summerDate);
const sunLen = Math.hypot(sun.x, sun.y, sun.z);
const sx = sun.x / sunLen;
const sy = sun.y / sunLen;
const sz = sun.z / sunLen;
// Point toward the Sun: must be sunlit
const daysideSat = { x: 7000 * sx, y: 7000 * sy, z: 7000 * sz };
assert.equal(isSatelliteSunlit(daysideSat, summerDate), true);
// Point directly in Earth shadow (opposite Sun, r = 6700 km, < Earth radius offset): must be eclipsed
const shadowedSat = { x: -6700 * sx, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(shadowedSat, summerDate), false);
// Point on night side but high altitude / far perpendicular to shadow axis (> 6371 km): must be sunlit
const highAltSat = { x: -6700 * sx + 10000, y: -6700 * sy, z: -6700 * sz };
assert.equal(isSatelliteSunlit(highAltSat, summerDate), true);
});
test('isSatelliteSunlit accurately identifies SGP4 orbital shadow entry and exit', () => {
const satrec = twoline2satrec(L1, L2);
// Minute 17 after FROM_MS (1221914820000): ISS is inside Earth umbra shadow cone
const tShadowed = FROM_MS + 17 * 60_000;
const pvShadowed = propagate(satrec, new Date(tShadowed));
assert.equal(
isSatelliteSunlit(pvShadowed.position, tShadowed),
false,
'ISS must be eclipsed in Earth umbra during orbital night',
);
// Minute 18 after FROM_MS (1221914880000): ISS emerges from umbra into sunlight
const tSunlit = FROM_MS + 18 * 60_000;
const pvSunlit = propagate(satrec, new Date(tSunlit));
assert.equal(
isSatelliteSunlit(pvSunlit.position, tSunlit),
true,
'ISS must be illuminated upon orbital sunrise',
);
});
test('findNextSatellitePass resolves pass boundaries with sub-second precision', () => {
const satrec = twoline2satrec(L1, L2);
const pass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
});
assert.ok(pass, 'expected a pass within 24h at Austin for ISS');
assert.ok(pass.riseMs > FROM_MS);
assert.ok(pass.riseMs < pass.maxElevMs && pass.maxElevMs < pass.setMs);
assert.ok(pass.maxElevDeg >= 10);
assert.ok(pass.riseAzDeg >= 0 && pass.riseAzDeg < 360);
// Verify bisection accuracy at rise time:
const beforeRise = lookAnglesAt(
satrec,
pass.riseMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atRise = lookAnglesAt(
satrec,
pass.riseMs,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const afterRise = lookAnglesAt(
satrec,
pass.riseMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeRise && beforeRise.elevDeg < 10.05,
'elevation before riseMs must be below threshold',
);
assert.ok(
atRise && Math.abs(atRise.elevDeg - 10) < 0.2,
'elevation at riseMs must be within 0.2° of 10°',
);
assert.ok(
afterRise && afterRise.elevDeg > 9.95,
'elevation after riseMs must be rising above threshold',
);
// Verify bisection accuracy at set time:
const beforeSet = lookAnglesAt(
satrec,
pass.setMs - 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
const atSet = lookAnglesAt(satrec, pass.setMs, AUSTIN.latDeg, AUSTIN.lonDeg);
const afterSet = lookAnglesAt(
satrec,
pass.setMs + 1000,
AUSTIN.latDeg,
AUSTIN.lonDeg,
);
assert.ok(
beforeSet && beforeSet.elevDeg > 9.95,
'elevation before setMs must be above threshold',
);
assert.ok(
atSet && Math.abs(atSet.elevDeg - 10) < 0.2,
'elevation at setMs must be within 0.2° of 10°',
);
assert.ok(
afterSet && afterSet.elevDeg < 10.05,
'elevation after setMs must be below threshold',
);
// Parabolic culmination accuracy: max elevation must be peak
assert.ok(pass.maxElevDeg >= atRise.elevDeg);
assert.ok(pass.maxElevDeg >= atSet.elevDeg);
});
test('findNextSatellitePass skips non-visible passes when requireVisible is true', () => {
const satrec = twoline2satrec(L1, L2);
const geometricPass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
requireVisible: false,
});
assert.ok(geometricPass);
const visiblePass = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: FROM_MS,
minElevDeg: 10,
horizonHours: 72,
requireVisible: true,
});
assert.ok(visiblePass, 'expected at least one visible pass within horizon');
assert.equal(visiblePass.visible, true);
assert.ok(visiblePass.riseMs >= geometricPass.riseMs);
});
test('findNextSatellitePass samples the peak at fineStepSec', () => {
const satrec = twoline2satrec(L1, L2);
const opts = { satrec, ...AUSTIN, fromMs: FROM_MS, minElevDeg: 10 };
const fine = findNextSatellitePass({ ...opts, fineStepSec: 1 });
const coarse = findNextSatellitePass({ ...opts, fineStepSec: 10 });
assert.ok(fine && coarse);
// The step is used: peak samples differ.
assert.notEqual(fine.maxElevMs, coarse.maxElevMs);
// Rise comes from bisection, so it does not depend on the step.
assert.ok(Math.abs(fine.riseMs - coarse.riseMs) < 1000);
// Parabolic refinement keeps 10s sampling close to the 1s peak.
assert.ok(Math.abs(fine.maxElevMs - coarse.maxElevMs) < 1000);
assert.ok(Math.abs(fine.maxElevDeg - coarse.maxElevDeg) < 0.01);
});
test('invalid numerical search options return promptly without unbounded scans', () => {
const satrec = twoline2satrec(L1, L2);
const options = { satrec, ...AUSTIN, fromMs: FROM_MS };
for (const change of [
{ coarseStepSec: 0 },
{ coarseStepSec: -1 },
{ horizonHours: Infinity },
{ horizonHours: 73 },
{ fineStepSec: NaN },
{ latDeg: 91 },
{ fromMs: NaN },
]) {
assert.equal(findNextSatellitePass({ ...options, ...change }), null);
}
});
test('visibility catches a short overlap between shadow exit and dawn', () => {
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2400, t <= 2500]),
true,
);
assert.equal(
hasVisibleInterval(0, 5000, (t) => [t >= 2500, t <= 2400]),
false,
);
});
test('a pass already underway clips rise to the requested start', () => {
const satrec = twoline2satrec(L1, L2);
const first = findNextSatellitePass({ satrec, ...AUSTIN, fromMs: FROM_MS });
const clipped = findNextSatellitePass({
satrec,
...AUSTIN,
fromMs: first.maxElevMs,
});
assert.equal(clipped.riseMs, first.maxElevMs);
assert.ok(clipped.setMs >= clipped.riseMs);
assert.ok(
clipped.maxElevMs >= clipped.riseMs && clipped.maxElevMs <= clipped.setMs,
);
});
+154
View File
@@ -0,0 +1,154 @@
// Focused tests for the satellite analyst-record mapper and snapshot seam.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import satellitesLayer, {
mapAnalystRecord,
_clearDenseCatalogStateForTest,
_setTrackedSatelliteRefreshStateForTest,
} from './satellites.js';
const ISS_NORAD = 25544;
const ISS_L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const ISS_L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const FULL_RAW = {
noradId: 25544,
name: 'ISS (ZARYA)',
group: 'stations',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
};
test('satellite analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RAW);
assert.deepEqual(r, {
id: 'ISS (ZARYA)',
noradId: '25544',
name: 'ISS (ZARYA)',
lat: 51.64,
lon: -97.4,
altitudeM: 410_000,
speedMps: 7660,
satelliteClass: 'STATION · ISS',
group: 'stations',
});
});
test('satellite analyst record: nameless row falls back to SAT-norad id', () => {
const r = mapAnalystRecord({ ...FULL_RAW, name: ' ', noradId: 25544 });
assert.equal(r.id, 'SAT-25544');
assert.equal(r.name, null);
});
test('satellite analyst record: ISS class wins even when ingested as visual', () => {
const r = mapAnalystRecord({ ...FULL_RAW, group: 'visual' });
assert.equal(r.satelliteClass, 'STATION · ISS');
});
test('satellite analyst record: GPS group maps to NAV · GPS', () => {
const r = mapAnalystRecord({
noradId: 24876,
name: 'GPS BIIR-2 (PRN 13)',
group: 'gps-ops',
lat: 0,
lon: 0,
altitudeM: 20_200_000,
speedMps: 3870,
});
assert.equal(r.satelliteClass, 'NAV · GPS');
assert.equal(r.group, 'gps-ops');
});
test('satellite analyst record: empty record yields nulls, never NaN/undefined', () => {
const r = mapAnalystRecord(undefined);
assert.equal(r.id, 'SAT-00000');
assert.equal(r.satelliteClass, null);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
test('satellite analyst record: output is JSON-safe (no Cesium types leak)', () => {
const r = mapAnalystRecord({
...FULL_RAW,
position: { x: 1 },
satrec: { dummy: true },
});
assert.deepEqual(JSON.parse(JSON.stringify(r)), r);
assert.equal('position' in r, false);
assert.equal('satrec' in r, false);
});
test('satellite getAnalystRecords: disabled or empty catalog returns []', () => {
_clearDenseCatalogStateForTest();
assert.deepEqual(satellitesLayer.getAnalystRecords(), []);
});
test('satellite getAnalystRecords: core rows beat dense extras under the cap', () => {
const satrec = twoline2satrec(ISS_L1, ISS_L2);
const point = {
position: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 420_000),
};
_setTrackedSatelliteRefreshStateForTest({
noradId: ISS_NORAD,
name: 'ISS (ZARYA)',
satrec,
entity: {},
point,
viewer: {},
now: () => Date.now(),
neighbours: [
{
noradId: 99999,
name: 'STARLINK-TEST',
satrec,
group: 'dense',
point: { position: Cesium.Cartesian3.fromDegrees(-90, 20, 550_000) },
},
{
noradId: 24876,
name: 'GPS BIIR-2',
satrec,
group: 'gps-ops',
point: {
position: Cesium.Cartesian3.fromDegrees(-100, 10, 20_200_000),
},
},
],
});
try {
const capped = satellitesLayer.getAnalystRecords(2);
assert.equal(capped.length, 2);
assert.deepEqual(
capped.map((r) => r.noradId),
['25544', '24876'],
);
assert.equal(
capped.some((r) => r.group === 'dense'),
false,
);
const all = satellitesLayer.getAnalystRecords(10);
assert.equal(all.length, 3);
assert.equal(all[2].noradId, '99999');
assert.equal(all[2].satelliteClass, 'COMMS · STARLINK');
for (const row of all) {
assert.ok(
Number.isFinite(row.lat) && Number.isFinite(row.lon),
`${row.id} needs a geodetic position`,
);
assert.deepEqual(JSON.parse(JSON.stringify(row)), row);
}
} finally {
_clearDenseCatalogStateForTest();
}
});
+3
View File
@@ -41,6 +41,7 @@ export const _clearSatelliteLabelLifecycleForTest =
export const applySatellitePointFocusDeemphasis =
layer.applySatellitePointFocusDeemphasis;
export const getNextIssPass = layer.getNextIssPass;
export const getNextSatellitePass = layer.getNextSatellitePass;
export const scoreSatelliteNameMatch = layer.scoreSatelliteNameMatch;
export const findSatelliteOrbitTrackInTle = layer.findSatelliteOrbitTrackInTle;
export const getSatelliteOrbitTrack = layer.getSatelliteOrbitTrack;
@@ -49,3 +50,5 @@ export {
ISS_OVERLAY_SOURCE_OPTIONS,
} from '../layers/satellites/index.js';
export default layer;
export { mapAnalystRecord } from '../layers/satellites/records.js';
+149
View File
@@ -0,0 +1,149 @@
/**
* @file Shared address contract for receiver taps.
*
* A "tap" is a layer fed by hardware the user owns and runs themselves. What
* this module governs is narrower than "tap" in general: it is for taps whose
* upstream address arrives **from the browser at request time**, which is what
* makes the route an SSRF surface. Today that is the Rayhunter device (#56),
* which is addressed as `?base=host:port`, and it would cover an RTL-SDR /
* dump1090 receiver (#57) if that one is built server-side.
*
* It deliberately does NOT cover the other layers in the receiver-taps lane,
* because they are a different shape and forcing them through this check would
* be wrong:
*
* - Meshtastic (#6) reads a fixed public broker (`mqtt.meshtastic.org`).
* - WiGLE (#11) reads a fixed public API behind a token.
* - TAK (#7) takes its host from operator-set env config, not from the
* browser, and a TAK server is frequently a legitimate REMOTE host — a
* private-only rule would break it.
*
* This module is the single place that constraint lives, so taps cannot each
* re-derive it and disagree. The rule is deliberately narrow:
*
* A tap may address the loopback interface or an RFC1918 private network,
* plus the `localhost` and `*.local` (mDNS) names. Nothing else.
*
* That is the whole point of a tap — it reads *your* device on *your* network.
* A tap pointed at a public address is either a mistake or someone using the
* proxy as a confused deputy, and both deserve the same answer.
*
* Two deliberate non-goals, so the next person does not think they were
* oversights:
*
* 1. No general hostname resolution. A name other than `localhost`/`*.local`
* is rejected outright rather than resolved-and-pinned. Local devices are
* reachable by IP or mDNS, so supporting arbitrary names would buy nothing
* and would drag in DNS-rebinding defence (resolve, pin, re-check on every
* redirect) that `server/providers/radio/transport.js` already shows is not
* small. If a tap ever genuinely needs it, pin it there rather than
* loosening the check here.
* 2. No IPv6. Link-local IPv6 carries a zone index (`%eth0`) whose handling is
* platform-specific, and no tap has needed it. Rejected explicitly rather
* than half-supported.
*
* Lives in `src/data/` rather than under `server/providers/` because both
* sides need the same answer: the server as a trust boundary, the browser to
* give immediate feedback on a typed address without a round trip. Sharing one
* module means they cannot drift — the same reason #274 exists about eight
* copies of haversine. `server/providers/gbfs.js` importing
* `src/data/gbfsSource.js` is the established direction for this.
*
* Note on reuse: `isNonGlobalIpv4()` in `server/providers/radio/stations.js` answers a
* neighbouring question and was deliberately NOT reused. It reports malformed
* input such as `999.1.1.1` as non-global, which is the safe direction for the
* radio proxy's "is this public enough to fetch?" test but inverts into
* *allowing* garbage under a tap's "is this local enough to fetch?" test. The
* positive allowlist below cannot invert that way.
*
* @module data/tapAddress
*/
/** Link-local (169.254/16) is excluded wholesale, which also covers the cloud
* metadata address at 169.254.169.254 by construction rather than by blocklist. */
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
/** Single mDNS label or dotted name ending in `.local`, e.g. `rayhunter.local`. */
const MDNS_RE = /^(?!-)[a-z0-9-]{1,63}(?:\.(?!-)[a-z0-9-]{1,63})*\.local$/;
const MAX_PORT = 65535;
/**
* Whether a dotted-quad string is a loopback or RFC1918 private address.
*
* Written as a positive allowlist: every octet must parse and be in range
* before any range test runs, so malformed input can never fall through to
* "allowed".
*
* @param {string} host
* @returns {boolean}
*/
export function isLocalIpv4(host) {
const match = IPV4_RE.exec(host);
if (!match) return false;
const [a, b] = [Number(match[1]), Number(match[2])];
const octets = match.slice(1).map(Number);
if (
octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
)
return false;
return (
a === 127 || // loopback
a === 10 || // 10/8
(a === 172 && b >= 16 && b <= 31) || // 172.16/12
(a === 192 && b === 168) // 192.168/16
);
}
/**
* Parse and validate a user-supplied `host:port` tap address.
*
* @param {unknown} raw e.g. `"192.168.1.1:8080"`, `"localhost:8080"`.
* @returns {{host: string, port: number, origin: string}|null} null when the
* address is malformed or not local. Callers must treat null as a 400 and
* must not fall back to a default host — a tap with no valid address has
* nothing to read.
*/
export function parseTapAddress(raw) {
const value = String(raw ?? '')
.trim()
.toLowerCase();
if (!value || value.length > 263) return null;
// Reject a scheme, credentials, path, or query outright rather than
// stripping them: each is a sign the caller meant a URL, not an address,
// and quietly repairing it is how a parser ends up disagreeing with the
// thing that later builds the request.
if (/[/\\?#@]/.test(value) || value.includes('://')) return null;
const colon = value.lastIndexOf(':');
if (colon <= 0 || colon === value.length - 1) return null;
const host = value.slice(0, colon);
const portText = value.slice(colon + 1);
if (!/^\d{1,5}$/.test(portText)) return null;
const port = Number(portText);
if (port < 1 || port > MAX_PORT) return null;
const hostOk =
host === 'localhost' || MDNS_RE.test(host) || isLocalIpv4(host);
if (!hostOk) return null;
return { host, port, origin: `http://${host}:${port}` };
}
/**
* Build an absolute upstream URL for a tap request.
*
* The path is supplied by the tap module, never by the client, so this only
* has to guarantee the two halves join predictably.
*
* @param {{origin: string}} address from {@link parseTapAddress}.
* @param {string} path absolute path beginning with `/`.
* @returns {string}
*/
export function tapUrl(address, path) {
if (!address?.origin)
throw new TypeError('tapUrl: address must come from parseTapAddress');
if (typeof path !== 'string' || !path.startsWith('/')) {
throw new TypeError('tapUrl: path must be an absolute path');
}
return `${address.origin}${path}`;
}
+147
View File
@@ -0,0 +1,147 @@
// src/data/tapAddress.test.mjs
// Contract tests for the shared receiver-tap address check. Pure — no server.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isLocalIpv4, parseTapAddress, tapUrl } from './tapAddress.js';
test('parseTapAddress: accepts loopback and every RFC1918 range', () => {
for (const address of [
'127.0.0.1:8080',
'10.0.0.5:1234',
'172.16.0.1:80',
'172.31.255.254:65535',
'192.168.1.1:8080',
'localhost:8080',
'rayhunter.local:8080',
'dump1090.lan.local:30003',
]) {
assert.ok(parseTapAddress(address), `${address} should be accepted`);
}
});
test('parseTapAddress: rejects public addresses — a tap reads your own network', () => {
for (const address of [
'8.8.8.8:80',
'1.1.1.1:443',
'93.184.216.34:80',
'example.com:80',
'api.wigle.net:443',
// 172.15 and 172.32 sit just outside 172.16/12.
'172.15.0.1:80',
'172.32.0.1:80',
// 192.167/192.169 bracket 192.168/16.
'192.167.1.1:80',
'192.169.1.1:80',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects link-local, so the cloud metadata address cannot be reached', () => {
assert.equal(parseTapAddress('169.254.169.254:80'), null);
assert.equal(parseTapAddress('169.254.1.1:80'), null);
});
test('parseTapAddress: malformed dotted-quads are rejected, not treated as private', () => {
// The trap this module exists to avoid: a classifier that reports garbage as
// "not public" would let these through under an is-it-local test.
for (const address of [
'999.1.1.1:80',
'10.0.0:80',
'10.0.0.1.5:80',
'10.0.0.256:80',
'-1.0.0.1:80',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects anything shaped like a URL rather than repairing it', () => {
for (const address of [
'http://127.0.0.1:8080',
'127.0.0.1:8080/api/status',
'127.0.0.1:8080?x=1',
'127.0.0.1:8080#f',
'[email protected]:8080',
'127.0.0.1:8080\\x',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects a missing, empty, or out-of-range port', () => {
for (const address of [
'127.0.0.1',
'127.0.0.1:',
'127.0.0.1:0',
'127.0.0.1:65536',
'127.0.0.1:abc',
':8080',
]) {
assert.equal(
parseTapAddress(address),
null,
`${address} should be rejected`,
);
}
});
test('parseTapAddress: rejects IPv6 explicitly rather than half-supporting it', () => {
assert.equal(parseTapAddress('[::1]:8080'), null);
assert.equal(parseTapAddress('::1:8080'), null);
assert.equal(parseTapAddress('[fe80::1%eth0]:8080'), null);
});
test('parseTapAddress: handles non-string and absent input without throwing', () => {
for (const value of [null, undefined, 0, {}, [], NaN, true]) {
assert.equal(parseTapAddress(value), null);
}
});
test('parseTapAddress: normalizes case and surrounding whitespace', () => {
const parsed = parseTapAddress(' RayHunter.LOCAL:8080 ');
assert.deepEqual(parsed, {
host: 'rayhunter.local',
port: 8080,
origin: 'http://rayhunter.local:8080',
});
});
test('isLocalIpv4: boundaries of each allowed range', () => {
assert.ok(isLocalIpv4('127.255.255.255'));
assert.ok(isLocalIpv4('10.255.255.255'));
assert.ok(isLocalIpv4('172.16.0.0'));
assert.ok(isLocalIpv4('172.31.255.255'));
assert.ok(!isLocalIpv4('172.15.255.255'));
assert.ok(!isLocalIpv4('172.32.0.0'));
assert.ok(!isLocalIpv4('126.255.255.255'));
assert.ok(!isLocalIpv4('128.0.0.1'));
});
test('tapUrl: joins a parsed address with a module-supplied path', () => {
const address = parseTapAddress('192.168.1.1:8080');
assert.equal(
tapUrl(address, '/api/qmdl-manifest'),
'http://192.168.1.1:8080/api/qmdl-manifest',
);
});
test('tapUrl: refuses an unparsed address or a relative path', () => {
const address = parseTapAddress('192.168.1.1:8080');
assert.throws(() => tapUrl(null, '/x'), TypeError);
assert.throws(() => tapUrl({ origin: '' }, '/x'), TypeError);
assert.throws(() => tapUrl(address, 'api/status'), TypeError);
assert.throws(() => tapUrl(address, ''), TypeError);
});
+46 -4
View File
@@ -123,12 +123,51 @@ export function deriveFetchCenter({
return { lat: pulled.lat, lon: pulled.lon, source: 'pulled' };
}
/**
* Wrapped longitude span between two meridians, in degrees.
*
* Longitude is cyclic, so a box crossing the antimeridian (±180°) has
* `east < west` (e.g. `west = 179.98, east = -179.98`). A plain `east - west`
* goes negative in that case; wrap through 360° instead.
*
* @param {number} west - West edge longitude (degrees).
* @param {number} east - East edge longitude (degrees).
* @returns {number} Span in degrees, always >= 0.
*/
function longitudeSpanDeg(west, east) {
const raw = east - west;
return raw >= 0 ? raw : raw + 360;
}
/** Keep a monotonic longitude range inside the traffic source's legal domain. */
function boundedLongitudeRange(center, span) {
let west = center - span / 2;
let east = center + span / 2;
if (east > 180) {
west -= east - 180;
east = 180;
}
if (west < -180) {
east += -180 - west;
west = -180;
}
return { west, east };
}
/**
* Clamp a bounding box's spans to `maxSpanDeg` and recenter it on `center`.
*
* Preserves the pre-C4 span semantics (each axis capped at 0.05° ≈ 5.5 km)
* but centers the box on the derived look-at point instead of the view
* rectangle's midpoint. Idempotent when `center` is the box's own midpoint.
* rectangle's midpoint. At the antimeridian the whole box shifts just far
* enough to remain inside [-180, 180], because the road source accepts one
* monotonic Overpass bounding box. Idempotent when `center` is the box's own
* midpoint.
*
* Longitude is handled as a cyclic axis for the span calculation, so the
* input span wraps correctly across the antimeridian. Output remains
* monotonic because callers such as `getBoundsCenter` and `boundsOverlap`
* rely on `west <= east`.
*
* @param {{south:number, west:number, north:number, east:number}} bounds
* Source bounds (span donor).
@@ -138,11 +177,14 @@ export function deriveFetchCenter({
*/
export function clampBoundsAroundCenter(bounds, center, maxSpanDeg = 0.05) {
const latSpan = Math.min(bounds.north - bounds.south, maxSpanDeg);
const lonSpan = Math.min(bounds.east - bounds.west, maxSpanDeg);
const lonSpan = Math.min(
longitudeSpanDeg(bounds.west, bounds.east),
maxSpanDeg,
);
const longitude = boundedLongitudeRange(center.lon, lonSpan);
return {
south: center.lat - latSpan / 2,
north: center.lat + latSpan / 2,
west: center.lon - lonSpan / 2,
east: center.lon + lonSpan / 2,
...longitude,
};
}
+16
View File
@@ -108,3 +108,19 @@ test('span clamp is idempotent on already-clamped bounds (loadRoadsForBounds re-
const twice = clampBoundsAroundCenter(once, midpoint, 0.05);
assert.deepEqual(twice, once);
});
test('span clamp keeps antimeridian bounds monotonic and request-safe', () => {
for (const centerLon of [179.99, -179.99]) {
const result = clampBoundsAroundCenter({
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
}, { lat: 0, lon: centerLon }, 0.05);
assert.ok(result.west >= -180 && result.east <= 180);
assert.ok(result.west <= result.east, `west/east not monotonic: ${result.west}, ${result.east}`);
assert.ok(Math.abs(result.east - result.west - 0.04) < 1e-12);
assert.ok(result.west <= centerLon && centerLon <= result.east);
}
});
+3 -2
View File
@@ -135,7 +135,7 @@ export function applyVesselOverlayPolicy(
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(12, rawGap + 8);
const gapPx = Math.max(selected ? 28 : 12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
@@ -144,7 +144,8 @@ export function applyVesselOverlayPolicy(
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
// Selected detail cards may move beside the contact to clear solid panels.
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: 0.7,
+1
View File
@@ -75,6 +75,7 @@ test('vessel host policy uses always-on shared fade and protected selected lane'
id: 'vessel:2', position, title: 'SELECTED', gapPx: 12, selected: true,
});
assert.equal(selected.variant, 'selected');
assert.equal(selected.verticalOnly, false, 'selected detail can clear panels on either side');
assert.equal(selected.protected, true);
assert.equal(selected.collisionGroup, 'ambient-card');
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
import { createSourceSlot } from '../sources/sourceSlot.js';
import { createWindLayer as createLayer } from '../layers/wind/index.js';
import { createWindSource } from '../layers/wind/source.js';
const sourceSlot = createSourceSlot(
createWindSource(),
['getSnapshot'],
'Wind source',
);
export const configureWindSource = sourceSlot.configure;
/** Create a wind layer using the configured application source by default. */
export function createWindLayer(options = {}) {
return createLayer({ ...options, feed: options.feed ?? sourceSlot.source });
}
export default createWindLayer();
+9
View File
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decodeWindGribMessage } from '../../server/providers/wind/decode.js';
test('decoder uses bounded virtual storage and releases both handle and message on errors', async () => {
const writes = []; let deleted = false;
const eccodesModule = { writeFile: (file, bytes) => writes.push(bytes.length), openGrib: () => ({ getLong: () => 1e9, delete: () => { deleted = true; } }) };
await assert.rejects(decodeWindGribMessage(new Uint8Array(20), { eccodesModule }), /geometry/);
assert.deepEqual(writes, [20,0]); assert.equal(deleted, true);
});
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
gfsObjectKey,
selectLatestGfsCycle,
} from '../../server/providers/wind/catalog.js';
import {
parseGfsIdx,
windMessageRanges,
fetchRange,
fetchGfsWind,
loadOptionalScalar,
} from '../../server/providers/wind/gfs.js';
test('catalog and index helpers', async () => {
assert.equal(
gfsObjectKey({ date: '20260914', hour: 6, forecastHour: 3 }),
'gfs.20260914/06/atmos/gfs.t06z.pgrb2.0p25.f003',
);
assert.deepEqual(selectLatestGfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
const p = parseGfsIdx(
'1:10:d=x:UGRD:10 m above ground:anl:\n2:20:d=x:VGRD:10 m above ground:anl:\n3:30:d=x:X:y:z',
);
assert.deepEqual(windMessageRanges(p), {
u: { start: 10, end: 19 },
v: { start: 20, end: 29 },
});
const b = await fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () =>
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
});
assert.deepEqual([...b], [1, 2, 3]);
assert.throws(() => windMessageRanges({ messages: p.messages.slice(0, 1) }));
});
test('wind range fetch rejects invalid offsets and caps chunked bodies', async () => {
await assert.rejects(
fetchRange({ url: 'x', start: -1, end: 2 }),
/range too large/,
);
let cancelled = false;
const body = new ReadableStream({
pull(controller) {
controller.enqueue(new Uint8Array(10));
},
cancel() {
cancelled = true;
},
});
await assert.rejects(
fetchRange({
url: 'x',
start: 0,
end: 2,
fetchImpl: async () => new Response(body, { status: 206 }),
}),
/budget/,
);
assert.equal(cancelled, true);
});
const WEATHER_IDX = [
'1:0:d=2026091506:UGRD:10 m above ground:6 hour fcst:',
'2:2:d=2026091506:VGRD:10 m above ground:6 hour fcst:',
'3:4:d=2026091506:TMP:850 mb:6 hour fcst:',
'4:6:d=2026091506:TMP:2 m above ground:6 hour fcst:',
'5:8:d=2026091506:PRMSL:mean sea level:6 hour fcst:',
'6:10:d=2026091506:HGT:surface:6 hour fcst:',
].join('\n');
test('GFS overlays select exact levels from the same issue and forecast object', async () => {
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'temperature' })
.scalar,
{ start: 6, end: 7 },
);
assert.deepEqual(
windMessageRanges(parseGfsIdx(WEATHER_IDX), { overlay: 'pressure' }).scalar,
{ start: 8, end: 9 },
);
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchGfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.idx')) return new Response(WEATHER_IDX);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 6 ? 293.15 : bytes[0] === 8 ? 101325 : bytes[0],
),
units: bytes[0] === 6 ? 'K' : bytes[0] === 8 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('.f006.idx'));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 20 : 1013.25),
) < 0.0001,
);
}
assert.throws(
() =>
windMessageRanges(
parseGfsIdx(WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')),
{ overlay: 'temperature' },
),
/missing TMP/,
);
});
test('GFS preserves wind when its optional temperature is missing or malformed', async () => {
for (const failure of ['missing', 'fetch', 'decode', 'geometry', 'units']) {
const result = await fetchGfsWind({
overlay: 'temperature',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 12),
fetchImpl: async (url, options) => {
if (url.endsWith('.idx'))
return new Response(
failure === 'missing'
? WEATHER_IDX.replace('TMP:2 m above ground', 'TMP:surface')
: WEATHER_IDX,
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 6 && failure === 'fetch')
throw new Error('private provider failure');
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => {
if (bytes[0] === 6 && failure === 'decode')
throw new Error('bad message');
return {
ni: 4,
nj: 3,
lo1: bytes[0] === 6 && failure === 'geometry' ? 90 : 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0] === 6 ? 293.15 : bytes[0]),
units: bytes[0] === 6 && failure !== 'units' ? 'K' : 'm/s',
};
},
});
assert.equal(result.scalar, undefined);
assert.equal(result.grid.scalar, undefined);
assert.equal(result.scalarError, 'Temperature field unavailable');
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T12:00:00.000Z');
}
});
test('optional scalar timeout cancels only its own fetch and honors parent cancellation', async () => {
const parent = new AbortController();
let active;
const value = await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'https://fixed.test/field',
timeoutMs: 5,
signal: parent.signal,
decodeImpl: () => {
throw new Error('not reached');
},
fetchImpl: (_, { signal }) => {
active = signal;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
});
assert.equal(value, undefined);
assert.equal(active.aborted, true);
assert.equal(parent.signal.aborted, false);
parent.abort();
let calls = 0;
assert.equal(
await loadOptionalScalar({
range: { start: 0, end: 1 },
url: 'x',
signal: parent.signal,
fetchImpl: () => {
calls++;
},
decodeImpl: () => {},
}),
undefined,
);
assert.equal(calls, 0);
});
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resampleWindGrid,
resampleWeatherFields,
} from '../../server/providers/wind/grid.js';
test('resamples and wraps a wind grid', () => {
const r = resampleWindGrid({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
u: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
v: new Array(12).fill(1),
dx: 90,
dy: 90,
});
assert.deepEqual([r.nx, r.ny], [4, 3]);
assert.equal(r.u[0], 0);
assert.equal(r.u[3], 3);
assert.equal(r.u[4], 4);
});
const component = (values, extra = {}) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(values),
...extra,
});
test('weather resampling converts temperature and MSL pressure on the wind geometry', () => {
const u = component(3);
const v = component(-4);
for (const [overlay, raw, units, expected, outUnits] of [
['temperature', 293.15, 'K', 20, '°C'],
['pressure', 101325, 'Pa', 1013.25, 'hPa'],
]) {
const result = resampleWeatherFields({
u,
v,
scalar: component(raw, { units }),
overlay,
targetDx: 90,
});
assert.equal(result.scalar.kind, overlay);
assert.equal(result.scalar.units, outUnits);
assert.deepEqual([...result.grid.u], Array(12).fill(3));
assert.deepEqual([...result.grid.v], Array(12).fill(-4));
assert.ok(result.grid.scalar.every((n) => Math.abs(n - expected) < 0.0001));
}
assert.equal(
'scalar' in resampleWeatherFields({ u, v, targetDx: 90 }).grid,
false,
);
});
test('weather components cannot share a grid when shifted, truncated or non-finite', () => {
const u = component(1);
const v = component(2);
const scalar = component(273.15, { units: 'K' });
for (const bad of [
{ ...v, lo1: 90 },
{ ...v, ni: 3 },
{ ...v, dj: 45 },
]) {
assert.throws(() => resampleWeatherFields({ u, v: bad }), /Mismatched/);
}
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, la1: 89 },
overlay: 'temperature',
}),
/Mismatched/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, units: '°C' },
overlay: 'temperature',
}),
/units/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: [1] },
overlay: 'temperature',
}),
/geometry/,
);
const invalid = component(1);
invalid.values[5] = NaN;
assert.throws(
() => resampleWeatherFields({ u, v: invalid, targetDx: 180 }),
/values/,
);
assert.throws(
() =>
resampleWeatherFields({
u,
v,
scalar: { ...scalar, values: invalid.values },
overlay: 'temperature',
}),
/values/,
);
assert.throws(
() => resampleWeatherFields({ u, v, overlay: 'dewpoint' }),
/Unknown/,
);
});
test('optional degradation cannot hide invalid wind geometry', async () => {
const { resampleWeatherSnapshot } =
await import('../../server/providers/wind/grid.js');
const u = component(1);
const v = component(2);
const result = resampleWeatherSnapshot({
u,
v,
overlay: 'pressure',
targetDx: 90,
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.throws(
() =>
resampleWeatherSnapshot({ u, v: { ...v, la1: 89 }, overlay: 'pressure' }),
/Mismatched/,
);
assert.throws(
() =>
resampleWindGrid({ ...u, u: u.values, v: v.values, dx: 0.25, dy: 0.25 }),
/budget/,
);
});
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ifsObjectUrls,
parseIfsIndex,
ifsWindRanges,
nearestIfsStep,
selectLatestIfsCycle,
fetchIfsWind,
} from '../../server/providers/wind/ifs.js';
import { nearestGfsStep } from '../../server/providers/wind/catalog.js';
test('IFS inventory selects only 10 m components and distinct index suffix', () => {
const urls = ifsObjectUrls({ date: '20260915', hour: 6, step: 9 });
assert.match(urls.index, /9h-oper-fc\.index$/);
assert.equal(urls.index.includes('.grib2.index'), false);
assert.deepEqual(
ifsWindRanges(
parseIfsIndex(
'{"param":"10u","_offset":20,"_length":10}\n{"param":"10v","_offset":40,"_length":15}',
),
),
{ u: { start: 20, end: 29 }, v: { start: 40, end: 54 } },
);
assert.deepEqual(selectLatestIfsCycle(Date.UTC(2026, 8, 15, 4)), {
date: '20260914',
hour: 18,
});
assert.equal(nearestIfsStep(8, 6), 9);
assert.equal(nearestIfsStep(200, 6), 144);
assert.equal(nearestIfsStep(151, 0), 150);
assert.equal(nearestGfsStep(8.2), 8);
});
test('IFS 2t and msl companions use the same issue/valid object as 10u and 10v', async () => {
const entries = ['10u', '10v', '2t', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
for (const overlay of ['none', 'temperature', 'pressure']) {
const calls = [];
const result = await fetchIfsWind({
overlay,
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
calls.push({ url, options });
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(
bytes[0] === 4 ? 283.15 : bytes[0] === 6 ? 100000 : bytes[0],
),
units: bytes[0] === 4 ? 'K' : bytes[0] === 6 ? 'Pa' : 'm/s',
}),
});
assert.equal(calls.length, overlay === 'none' ? 3 : 4);
assert.equal(new Set(calls.slice(1).map((c) => c.url)).size, 1);
assert.ok(calls.every((c) => c.options.redirect === 'error'));
assert.ok(calls[0].url.endsWith('-9h-oper-fc.index'));
assert.equal(result.cycle.runIso, '2026-09-15T06:00:00.000Z');
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
if (overlay === 'none') assert.equal(result.scalar, undefined);
else
assert.ok(
Math.abs(
result.grid.scalar[0] - (overlay === 'temperature' ? 10 : 1000),
) < 0.0001,
);
}
assert.throws(
() =>
ifsWindRanges(
parseIfsIndex(
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join('\n'),
),
{ overlay: 'pressure' },
),
/missing msl/,
);
});
test('IFS optional pressure refusal preserves same-cycle wind', async () => {
const entries = ['10u', '10v', 'msl'].map((param, i) => ({
param,
_offset: i * 2,
_length: 2,
}));
const result = await fetchIfsWind({
overlay: 'pressure',
targetDx: 90,
now: () => Date.UTC(2026, 8, 15, 14),
fetchImpl: async (url, options) => {
if (url.endsWith('.index'))
return new Response(
entries.map((entry) => JSON.stringify(entry)).join('\n'),
);
const start = Number(/bytes=(\d+)-/.exec(options.headers.Range)[1]);
if (start === 4) return new Response('Unavailable', { status: 503 });
return new Response(Uint8Array.of(start, 0), { status: 206 });
},
decodeImpl: async (bytes) => ({
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: new Float64Array(12).fill(bytes[0]),
}),
});
assert.equal(result.scalarError, 'Mean sea level pressure field unavailable');
assert.equal(result.grid.scalar, undefined);
assert.deepEqual([...result.grid.v], Array(12).fill(2));
assert.equal(result.cycle.validIso, '2026-09-15T15:00:00.000Z');
});
+657
View File
@@ -0,0 +1,657 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { windProxy } from '../../server/providers/wind.js';
/** Boot a mounted provider middleware without a real server. */
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: {
use(route, handler) {
routes.set(route, handler);
},
},
});
assert.equal(routes.size, 1);
const handler = [...routes.values()][0];
return async (url = '/', method = 'GET') => {
const res = {
statusCode: 200,
headers: {},
headersSent: false,
writeHead(status, headers) {
this.statusCode = status;
for (const [name, value] of Object.entries(headers || {}))
this.headers[name.toLowerCase()] = value;
this.headersSent = true;
},
end(body) {
this.body = body;
this.headersSent = true;
},
};
await handler({ url, method }, res);
return res;
};
}
const IDX = [
'1:0:d=2026091400:UGRD:10 m above ground:anl:',
'2:5:d=2026091400:VGRD:10 m above ground:anl:',
'3:10:d=2026091400:HGT:surface:anl:',
].join('\n');
const DECODED_U = {
ni: 4,
nj: 3,
lo1: 0,
la1: 90,
di: 90,
dj: 90,
values: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
};
const DECODED_V = {
...DECODED_U,
values: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32],
};
/** Resampled-grid metadata shared by the injected model fakes. */
const GRID = { nx: 4, ny: 3, lo1: 0, la1: 90, dx: 90, dy: 90 };
/** Fetch double: `.idx` text, and range bodies of the requested length. */
function makeFetch({ fail = false, counter } = {}) {
return async (url, options = {}) => {
if (counter) counter.calls += 1;
if (fail) throw new Error('gfs upstream down');
if (String(url).endsWith('.idx')) return new Response(IDX, { status: 200 });
const match = /bytes=(\d+)-(\d+)/.exec(options.headers?.Range || '');
const length = Number(match[2]) - Number(match[1]) + 1;
return new Response(new Uint8Array(length), { status: 206 });
};
}
function proxy(options = {}) {
return windProxy({
now: () => Date.UTC(2026, 8, 14, 12),
targetDx: 90,
fetchImpl: makeFetch(),
decodeImpl: async (buffer) =>
buffer[0] === undefined ? DECODED_U : DECODED_U,
...options,
});
}
test('wind manifest describes the GFS cycle and resampled grid', async () => {
const request = install(proxy());
const res = await request('/');
assert.equal(res.statusCode, 200);
const body = JSON.parse(res.body);
assert.equal(body.schemaVersion, 1);
assert.equal(body.model, 'gfs');
assert.equal(body.cycle.hour, 6);
assert.equal(body.cycle.date, '20260914');
assert.equal(body.units, 'm/s');
assert.deepEqual(body.grid, {
nx: 4,
ny: 3,
lo1: 0,
la1: 90,
dx: 90,
dy: 90,
});
assert.match(
body.gridUrl,
/^\/api\/wind\/grid\/gfs-20260914-6-f6-90\.bin\?model=gfs$/,
);
assert.equal(body.stale, false);
});
test('wind grid route returns Float32 U then V', async () => {
let u = 0;
const request = install(
proxy({
decodeImpl: async () => {
u += 1;
return u === 1 ? DECODED_U : DECODED_V;
},
}),
);
const manifest = JSON.parse((await request('/')).body);
const res = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(res.statusCode, 200);
assert.equal(res.headers['content-type'], 'application/octet-stream');
assert.equal(res.body.length, 4 * 3 * 4 * 2);
const floats = new Float32Array(
res.body.buffer,
res.body.byteOffset,
res.body.length / 4,
);
assert.deepEqual([...floats.slice(0, 12)], DECODED_U.values);
assert.deepEqual([...floats.slice(12)], DECODED_V.values);
});
test('wind status omits the grid url', async () => {
const request = install(proxy());
await request('/');
const body = JSON.parse((await request('/status')).body);
assert.equal('gridUrl' in body, false);
assert.equal(body.model, 'gfs');
});
test('wind refresh is cached within the TTL and refetches after it', async () => {
const counter = { calls: 0 };
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({ now: () => clock, fetchImpl: makeFetch({ counter }) }),
);
await request('/');
const afterFirst = counter.calls;
assert.equal(afterFirst, 3, 'one idx plus two range fetches');
await request('/');
assert.equal(
counter.calls,
afterFirst,
'a second request inside the TTL must not refetch',
);
clock += 2 * 3600_000;
await request('/');
assert.equal(
counter.calls,
afterFirst + 3,
'a request past the TTL refetches',
);
});
test('wind serves last-good with stale on upstream failure', async () => {
let failing = false;
let clock = Date.UTC(2026, 8, 14, 12);
const request = install(
proxy({
now: () => clock,
fetchImpl: async (...args) => {
if (failing) throw new Error('gfs upstream down');
return makeFetch()(...args);
},
}),
);
const good = JSON.parse((await request('/')).body);
failing = true;
clock += 2 * 3600_000;
const degraded = JSON.parse((await request('/')).body);
assert.equal(degraded.stale, true);
assert.equal(degraded.reason, 'Wind upstream unavailable');
const frame = await request(good.gridUrl.replace('/api/wind', ''));
assert.equal(frame.statusCode, 200, 'last-good grid stays addressable');
});
test('wind returns a JSON 404 for an unknown grid', async () => {
const request = install(proxy());
await request('/');
const res = await request('/grid/nope.bin');
assert.equal(res.statusCode, 404);
assert.deepEqual(JSON.parse(res.body), { error: 'unknown_grid' });
});
test('wind grid ids and URLs are model-scoped', async () => {
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
ifs: async () => ({
cycle: { date: '20260914', hour: 0 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: new Float32Array(12), v: new Float32Array(12) },
}),
},
}),
);
const gfs = JSON.parse((await request('/')).body);
const ifs = JSON.parse((await request('/?model=ifs')).body);
assert.match(gfs.gridUrl, /gfs-20260914-6-f0-90\.bin\?model=gfs$/);
assert.match(ifs.gridUrl, /ifs-20260914-0-f0-90\.bin\?model=ifs$/);
// Each model's grid resolves through its own model parameter.
assert.equal(
(await request(gfs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
assert.equal(
(await request(ifs.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind rejects a non-finite grid instead of caching it', async () => {
const bad = new Float32Array(12);
bad[3] = Number.POSITIVE_INFINITY;
const request = install(
proxy({
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6 },
level: 'x',
units: 'm/s',
grid: { ...GRID, u: bad, v: new Float32Array(12) },
}),
},
}),
);
const body = JSON.parse((await request('/')).body);
assert.equal(body.unavailable, true);
assert.equal(body.reason, 'Wind upstream unavailable');
});
test('wind rejects unknown models, paths and methods before acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error();
},
},
}),
);
assert.equal((await request('/?model=toString')).statusCode, 400);
assert.equal((await request('/secret')).statusCode, 404);
assert.equal((await request('/manifest', 'POST')).statusCode, 405);
assert.equal((await request('/grid/unknown.bin')).statusCode, 404);
assert.equal(calls, 0);
});
test('wind caches forecast steps separately and keeps a prior issued grid readable', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async () => ({
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(step),
v: new Float32Array(12),
},
}),
},
}),
);
const first = JSON.parse((await request('/')).body);
clock += 3600_000;
step = 7;
const second = JSON.parse((await request('/')).body);
assert.notEqual(first.gridUrl, second.gridUrl);
assert.equal(
(await request(first.gridUrl.replace('/api/wind', ''))).statusCode,
200,
);
});
test('wind failure backoff prevents unbounded repeated upstream acquisition', async () => {
let calls = 0;
const request = install(
proxy({
models: {
gfs: async () => {
calls++;
throw new Error('private upstream detail');
},
},
}),
);
const results = await Promise.all([request('/'), request('/')]);
assert.equal(calls, 1);
assert.equal(JSON.parse(results[0].body).reason, 'Wind upstream unavailable');
await request('/');
assert.equal(calls, 1);
});
test('shared upstream work survives one disconnect and aborts when the final client leaves', async () => {
const { EventEmitter } = await import('node:events');
let handler;
let signal;
let loads = 0;
const plugin = proxy({
models: {
gfs: ({ signal: next }) => {
loads++;
signal = next;
return new Promise((resolve, reject) =>
next.addEventListener('abort', () => reject(next.reason), {
once: true,
}),
);
},
},
});
plugin.configureServer({
middlewares: {
use: (_path, value) => {
handler = value;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
const a = handler({ url: '/manifest', method: 'GET' }, first);
const b = handler({ url: '/manifest', method: 'GET' }, second);
assert.equal(loads, 1);
first.emit('close');
assert.equal(signal.aborted, false);
second.emit('close');
assert.equal(signal.aborted, true);
await Promise.all([a, b]);
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
function weatherModel({ overlay = 'none', step = 6 } = {}) {
const scalar =
overlay === 'none'
? null
: {
kind: overlay,
units: overlay === 'temperature' ? '°C' : 'hPa',
level:
overlay === 'temperature' ? '2 m above ground' : 'mean sea level',
};
return {
cycle: { date: '20260914', hour: 6, forecastHour: step },
level: '10 m above ground',
units: 'm/s',
grid: {
...GRID,
u: new Float32Array(12).fill(1),
v: new Float32Array(12).fill(2),
...(scalar
? {
scalar: new Float32Array(12).fill(
overlay === 'temperature' ? 20 : 1013.25,
),
}
: {}),
},
...(scalar ? { scalar } : {}),
};
}
test('weather variants cache independently, bind binary URLs, and retain two generations per slot', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let step = 6;
const calls = [];
const model =
(name) =>
async ({ overlay }) => {
calls.push(`${name}:${overlay}`);
return weatherModel({ overlay, step });
};
const request = install(
proxy({
now: () => clock,
models: { gfs: model('gfs'), ifs: model('ifs') },
}),
);
const slots = [];
for (const name of ['gfs', 'ifs'])
for (const overlay of ['none', 'temperature', 'pressure']) {
const query = `?model=${name}&overlay=${overlay}`;
const [a, b] = await Promise.all([
request(`/manifest${query}`),
request(`/manifest${query}`),
]);
const manifest = JSON.parse(a.body);
assert.deepEqual(JSON.parse(b.body), manifest);
assert.equal(
manifest.scalar?.kind,
overlay === 'none' ? undefined : overlay,
);
const binary = await request(manifest.gridUrl.replace('/api/wind', ''));
assert.equal(binary.body.length, 12 * (overlay === 'none' ? 8 : 12));
const floats = new Float32Array(
binary.body.buffer,
binary.body.byteOffset,
binary.body.length / 4,
);
assert.deepEqual(
[...floats.slice(0, 24)],
[...Array(12).fill(1), ...Array(12).fill(2)],
);
if (overlay !== 'none') {
assert.deepEqual(
[...floats.slice(24)],
Array(12).fill(overlay === 'temperature' ? 20 : 1013.25),
);
assert.equal(
(
await request(
manifest.gridUrl
.replace('/api/wind', '')
.replace(`&overlay=${overlay}`, ''),
)
).statusCode,
404,
);
}
slots.push({ query, first: manifest.gridUrl });
}
assert.equal(
calls.length,
6,
'one shared acquisition for each bounded variant',
);
for (const slot of slots) await request(`/manifest${slot.query}`);
assert.equal(calls.length, 6);
clock += 3600_000;
step = 7;
for (const slot of slots)
slot.second = JSON.parse(
(await request(`/manifest${slot.query}`)).body,
).gridUrl;
for (const slot of slots)
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
200,
);
clock += 3600_000;
step = 8;
for (const slot of slots) {
await request(`/manifest${slot.query}`);
assert.equal(
(await request(slot.first.replace('/api/wind', ''))).statusCode,
404,
);
assert.equal(
(await request(slot.second.replace('/api/wind', ''))).statusCode,
200,
);
}
});
test('overlay failures retain only their own last-good scalar and unknown overlays never acquire', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let failing = false;
let calls = 0;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
calls++;
if (failing && overlay !== 'none') throw new Error('private detail');
return weatherModel({ overlay });
},
},
}),
);
const first = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
await request('/manifest');
failing = true;
clock += 3600_000;
const stale = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(stale.stale, true);
assert.equal(stale.gridUrl, first.gridUrl);
assert.equal(stale.scalar.kind, 'temperature');
assert.equal(
JSON.parse((await request('/manifest?overlay=pressure')).body).unavailable,
true,
);
assert.equal(JSON.parse((await request('/manifest')).body).stale, false);
const before = calls;
for (const overlay of ['toString', 'humidity', 'temperature,pressure']) {
assert.equal(
(await request(`/manifest?overlay=${overlay}`)).statusCode,
400,
);
}
assert.equal(calls, before);
});
test('weather provider rejects malformed scalar metadata, lengths and non-finite values', async () => {
const mutations = [
(value) => {
value.scalar.units = 'K';
},
(value) => {
value.grid.scalar = new Float32Array(11);
},
(value) => {
value.grid.scalar[3] = Infinity;
},
(value) => {
value.grid.v = new Float32Array(1);
},
];
for (const mutate of mutations) {
const request = install(
proxy({
models: {
gfs: async ({ overlay }) => {
const value = weatherModel({ overlay });
mutate(value);
return value;
},
},
}),
);
const manifest = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(manifest.unavailable, true);
assert.equal(manifest.gridUrl, undefined);
}
});
test('disconnecting an overlay request does not abort a different variant', async () => {
const { EventEmitter } = await import('node:events');
let handler;
const pending = new Map();
proxy({
models: {
gfs: ({ overlay, signal }) =>
new Promise((resolve, reject) => {
pending.set(overlay, { signal, resolve });
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
});
}),
},
}).configureServer({
middlewares: {
use: (_path, fn) => {
handler = fn;
},
},
});
const first = new EventEmitter();
const second = new EventEmitter();
second.writeHead = () => {};
second.end = (body) => {
second.body = body;
};
const a = handler(
{ url: '/manifest?overlay=temperature', method: 'GET' },
first,
);
const b = handler(
{ url: '/manifest?overlay=pressure', method: 'GET' },
second,
);
first.emit('close');
assert.equal(pending.get('temperature').signal.aborted, true);
assert.equal(pending.get('pressure').signal.aborted, false);
pending.get('pressure').resolve(weatherModel({ overlay: 'pressure' }));
await Promise.all([a, b]);
assert.equal(JSON.parse(second.body).scalar.kind, 'pressure');
assert.equal(first.listenerCount('close'), 0);
assert.equal(second.listenerCount('close'), 0);
});
test('optional scalar failure serves wind and recovery has a distinct immutable grid URL', async () => {
let clock = Date.UTC(2026, 8, 14, 12);
let missing = true;
const request = install(
proxy({
now: () => clock,
models: {
gfs: async ({ overlay }) => {
if (!missing) return weatherModel({ overlay });
return {
...weatherModel(),
scalarError: 'Temperature field unavailable',
};
},
},
}),
);
const partial = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.equal(partial.overlay, 'temperature');
assert.equal(partial.unavailable, false);
assert.equal(partial.stale, false);
assert.equal(partial.scalar, undefined);
assert.equal(partial.scalarError, 'Temperature field unavailable');
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
clock += 3600_000;
missing = false;
const complete = JSON.parse(
(await request('/manifest?overlay=temperature')).body,
);
assert.deepEqual(
complete.cycle,
partial.cycle,
'recovery is the same cycle without mixing components',
);
assert.equal(complete.scalar.kind, 'temperature');
assert.equal(complete.scalarError, undefined);
assert.notEqual(
complete.gridUrl,
partial.gridUrl,
'8-byte and 12-byte payloads cannot reuse immutable URL',
);
assert.equal(
(await request(complete.gridUrl.replace('/api/wind', ''))).body.length,
12 * 12,
);
assert.equal(
(await request(partial.gridUrl.replace('/api/wind', ''))).body.length,
12 * 8,
);
});
+3 -3
View File
@@ -657,15 +657,15 @@ test('the DISPLAY rail starts collapsed on a first run, and a stored choice wins
// ── Voice: instruction-only, tool schema unchanged ─────────────────────
test('the voice TOOL SCHEMA matches the pinned release — the mission mapping is instructions only', () => {
// ALPR deliberately adds its ID to the two layer menus and visibility aliases.
// Analyst layers and the separate satellite-pass tool deliberately extend the schema.
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const block = JSON.stringify(GEV_REALTIME_TOOLS);
assert.equal(block.length, 26208, 'serialized tool schema length drifted');
assert.equal(block.length, 27271, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'135d4ec66239777da34a8476cdf8348574421d7afd2e981cc3490909a5bc8686',
'da5f1bb334d1e882d63098cb35b29afde273947c0807ef6af2ef3d25a0df4918',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(new URL('../server/providers/openai/instructions.js', import.meta.url), 'utf8');
+24 -4
View File
@@ -24,7 +24,12 @@ import {
geoidHeight,
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import { isHudSummaryUnconfigured } from './hudSummaryResponse.js';
import {
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -649,7 +654,11 @@ export class IntelHUD {
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
const localityTag = composeLocalityTag(nearest, m.latDeg, m.lonDeg);
return `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
const provenance = hudTelemetryProvenanceTag(
this._dataManager?.getAll?.() || [],
);
const line = `${modeLabel} ${band} ${localityTag} | ${region} | ALT ${altTag} | WINDOW ${winTag} | SUN ${m.sunEl.toFixed(0)}° | ONA ${m.ona.toFixed(0)}° | ${localTag}`;
return provenance ? `${line} | ${provenance}` : line;
}
/**
@@ -734,7 +743,12 @@ export class IntelHUD {
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
this._setSummaryText(data.summary, animate);
this._setSummaryText(
hudSummaryMatchesProvenance(data.summary, context.feedProvenance)
? data.summary
: fallbackText,
animate,
);
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('[HUD] AI summary unavailable:', error);
@@ -775,6 +789,7 @@ export class IntelHUD {
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
enabledLayerLabels: enabledLayers,
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
};
}
@@ -904,7 +919,12 @@ export class IntelHUD {
this._dataManager = dataManager || null;
if (typeof this._dataManager?.subscribe === 'function') {
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
if (change?.type === 'visibility') this._markSummaryDirty();
if (
['visibility', 'refresh-transition', 'refresh-cancelled'].includes(
change?.type,
)
)
this._markSummaryDirty();
});
}
this._markSummaryDirty();
+74
View File
@@ -1,3 +1,8 @@
import {
feedProvenanceEnvelope,
layerSnapshots,
} from './data/layerSnapshot.js';
export const HUD_SUMMARY_UNCONFIGURED_CODE = 'OPENAI_NOT_CONFIGURED';
/**
@@ -36,3 +41,72 @@ export function isHudSummaryUnconfigured(status, data) {
data?.summary === null
);
}
/**
* HUD summary model instructions. The five-word line may only describe
* supplied labels, and must carry feed-state when any enabled layer is not
* nominal.
*/
export const HUD_SUMMARY_INSTRUCTIONS = [
"Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, enabled-layer labels, and feedProvenance.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'If any enabled layer is not nominal, the five words MUST include that feedState token (STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE) and must not present the view as live.',
'Do not infer from coordinates or invent a place or feed state.',
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
].join(' ');
/**
* Stamp HUD summary context with the same layer snapshots voice tools use.
* @param {Array<object>} [layers] `DataLayerManager.getAll()` rows.
* @param {{ now?: number }} [options]
* @returns {{
* enabledLayerLabels: string[],
* enabledLayers: Array<{id: string|null, name: string|null, feedState: string, source: string|null, count: number}>,
* feedProvenance: object,
* }}
*/
export function hudSummaryLayerContext(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
return {
enabledLayerLabels: snapshots.map((s) => s.name).filter(Boolean),
enabledLayers: snapshots.map((s) => ({
id: s.id,
name: s.name,
feedState: s.feedState,
source: s.source,
count: s.count,
})),
feedProvenance: envelope,
};
}
/**
* Deterministic telemetry suffix when an enabled feed is not nominal.
* @param {Array<object>} [layers]
* @param {{ now?: number }} [options]
* @returns {string|null}
*/
export function hudTelemetryProvenanceTag(layers = [], options) {
const snapshots = layerSnapshots(layers, options).filter((s) => s.enabled);
const envelope = feedProvenanceEnvelope(snapshots, options);
if (!envelope.overall || envelope.overall === 'nominal') return null;
const names = envelope.layers
.filter(
(s) => s.feedState && s.feedState !== 'nominal' && s.feedState !== 'off',
)
.map((s) => String(s.name || s.id || 'LAYER').toUpperCase())
.slice(0, 2);
return `${envelope.overall.toUpperCase()}${names.length ? ` ${names.join('/')}` : ''}`;
}
/** Require the supplied non-nominal state before showing an AI summary. */
export function hudSummaryMatchesProvenance(summary, provenance) {
const state = provenance?.overall;
if (!state || state === 'nominal' || state === 'off') return true;
return String(summary || '')
.toUpperCase()
.split(/\W+/)
.includes(String(state).toUpperCase());
}
+49
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import {
HUD_SUMMARY_INSTRUCTIONS,
HUD_SUMMARY_UNCONFIGURED_CODE,
hudSummaryLayerContext,
hudTelemetryProvenanceTag,
isHudSummaryUnconfigured,
keylessHudSummaryResponse,
} from './hudSummaryResponse.js';
@@ -128,3 +132,48 @@ test('the installed keyless HUD route stays successful after the voice quota is
else process.env.GEV_RATELIMIT_OPENAI_PER_MIN = previousLimit;
}
});
test('HUD summary context and telemetry tag carry chip-identical feed-state', () => {
const now = 1_000_000;
const layers = [
{
id: 'flights',
name: 'Live Flights',
enabled: true,
stats: { stale: true, count: 12, lastUpdate: now - 240_000, source: 'OpenSky Network' },
},
{
id: 'earthquakes',
name: 'Earthquakes',
enabled: false,
stats: { count: 3, lastUpdate: now },
},
];
const context = hudSummaryLayerContext(layers, { now });
assert.deepEqual(context.enabledLayerLabels, ['Live Flights']);
assert.equal(context.enabledLayers[0].feedState, 'stale');
assert.equal(context.feedProvenance.overall, 'stale');
assert.equal(hudTelemetryProvenanceTag(layers, { now }), 'STALE LIVE FLIGHTS');
assert.equal(hudTelemetryProvenanceTag([{
id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 4, lastUpdate: now },
}], { now }), null);
});
test('HUD summary instructions require non-nominal feedState in the five words', () => {
assert.match(HUD_SUMMARY_INSTRUCTIONS, /five words MUST include that feedState token/);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /STALE, DEGRADED, FALLBACK, LOADING, or UNAVAILABLE/);
assert.match(HUD_SUMMARY_INSTRUCTIONS, /feedProvenance/);
});
test('the HUD proxy uses the shared provenance instructions', () => {
const local = readFileSync(new URL('../server/providers/openai/hud-summary.js', import.meta.url), 'utf8');
assert.match(local, /HUD_SUMMARY_INSTRUCTIONS/);
assert.doesNotMatch(local, /enabled-layer text labels/);
});
test('AI summaries missing a non-nominal provenance token fall back deterministically', async () => {
const { hudSummaryMatchesProvenance } = await import('./hudSummaryResponse.js');
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'stale' }), false);
assert.equal(hudSummaryMatchesProvenance('Austin stale flights over downtown', { overall: 'stale' }), true);
assert.equal(hudSummaryMatchesProvenance('Austin flights operating normally today', { overall: 'nominal' }), true);
});
+1
View File
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
createCctvProjectionOverlayEntry:
parts.projection.createCctvProjectionOverlayEntry,
getActiveVideoElement: parts.projection.getActiveVideoElement,
setCctvCardPresentationOptions:
parts.cards.setCctvCardPresentationOptions,
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
+3
View File
@@ -83,6 +83,9 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+72 -19
View File
@@ -1,3 +1,4 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
@@ -207,6 +208,7 @@ export function createProjection({
// only re-uploads the plane texture when there is genuinely new content.
canvasStamp: 1,
lastSwappedCanvasStamp: 0,
disposed: false,
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
@@ -214,16 +216,49 @@ export function createProjection({
if (mode === 'video') {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = parts.frames.mediaUrlFor(record.camera);
video.addEventListener('canplay', () => {
video.play().catch(() => {});
});
// Cesium sizes the video texture from the element's width/height
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
parts.frames.mediaUrlFor(record.camera),
feedType,
{
onFailure: () => {
if (runtime.disposed) return;
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
},
);
} else {
const img = new Image();
img.decoding = 'async';
@@ -254,7 +289,7 @@ export function createProjection({
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: mode === 'video' && runtime.video ? runtime.video : canvas,
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
@@ -271,7 +306,8 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection) return record.projection;
if (record.projection && !record.projection.disposed)
return record.projection;
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
@@ -287,7 +323,15 @@ export function createProjection({
*/
function destroyProjectionRuntime(runtime) {
if (!runtime) return;
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
runtime.image.onload = null;
runtime.image.onerror = null;
runtime.image.src = '';
}
if (runtime.video) {
runtime.video.pause();
runtime.video.removeAttribute('src');
@@ -324,10 +368,7 @@ export function createProjection({
const active = parts.selection.getActiveRecord();
if (layerState._enabled && layerState._showProjection && active) {
ensureProjectionRuntime(active);
if (active.projection?.video) {
active.projection.video.play().catch(() => {});
}
if (active.projection) {
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
}
@@ -355,22 +396,34 @@ export function createProjection({
* @param {string|null} activeId - ID of the currently active camera.
*/
/**
* The active camera's decoded <video>, for a second surface (the panel card)
* to paint from. Null when the active feed is a still or not yet attached.
* @returns {HTMLVideoElement|null}
*/
function getActiveVideoElement() {
if (!layerState._enabled) return null;
return parts.selection.getActiveRecord()?.projection?.video || null;
}
function pauseInactiveProjectionFeeds(activeId) {
for (const record of layerState._records) {
if (!record.projection?.video) continue;
if (
record.camera.id === activeId &&
layerState._enabled &&
layerState._showProjection
) {
if (record.camera.id === activeId && layerState._enabled) {
record.projection.video.play().catch(() => {});
} else {
record.projection.video.pause();
const runtime = record.projection;
destroyProjectionRuntime(runtime);
record.projection = null;
layerState._projectionEntities = layerState._projectionEntities.filter(
(entry) => entry !== runtime,
);
}
}
}
return {
createCctvProjectionOverlayEntry,
getActiveVideoElement,
updatePlanePlacement,
clearProjectionOverlay,
setPlaneVisible,
+6 -1
View File
@@ -215,7 +215,12 @@ export function createRendering({
// (set at creation) — see the field-test far-zoom submerge fix there.
}
if (layerState._enabled && layerState._showProjection && isActive) {
if (
layerState._enabled &&
isActive &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
) {
parts.projection.ensureProjectionRuntime(record);
}
// One live plane in the world at a time (§2c): only the active camera's
+111
View File
@@ -0,0 +1,111 @@
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
url,
feedType,
{
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
: url;
let timer = null;
let startup = null;
let governor = null;
let retries = 0;
const release = () => {
if (feedType === 'hls') {
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
() => {},
);
}
};
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
clearTimeout(startup);
clearInterval(governor);
hls?.destroy();
hls = null;
video.removeEventListener('canplay', play);
video.removeEventListener('error', fail);
video.pause();
video.removeAttribute('src');
video.load();
release();
};
const fail = () => {
if (disposed) return;
dispose();
onFailure();
};
const play = () => {
if (disposed) return;
clearTimeout(startup);
video.play().catch(() => {});
};
video.addEventListener('canplay', play);
video.addEventListener('error', fail);
startup = setTimeout(fail, 30000);
const ready = (async () => {
if (feedType !== 'hls') {
video.src = mediaUrl;
return;
}
try {
const { default: Hls } = await loadHls();
if (disposed) return;
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl'))
video.src = mediaUrl;
else fail();
return;
}
hls = new Hls({
enableWorker: true,
maxBufferLength: 24,
maxMaxBufferLength: 30,
backBufferLength: 0,
maxBufferSize: 16 * 1024 * 1024,
liveSyncDurationCount: 3,
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (disposed || !data?.fatal) return;
if (++retries > 2) {
fail();
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (disposed) return;
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.loadSource(mediaUrl);
}, 2000);
});
governor = setInterval(() => {
if (disposed || !video.buffered?.length) return;
const ahead =
video.buffered.end(video.buffered.length - 1) - video.currentTime;
// Some agency encoders publish less media time than wall time. Bound
// correction rather than repeatedly draining the live buffer at 1x.
video.playbackRate =
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
}, 1000);
hls.attachMedia(video);
hls.loadSource(mediaUrl);
} catch {
fail();
}
})();
return { dispose, ready };
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
function video() {
const v = new EventTarget();
Object.assign(v, {
src: '',
pause() {},
load() {},
removeAttribute() {},
play: () => Promise.resolve(),
});
return v;
}
test('switch during lazy import never starts the stale decoder', async () => {
let resolve;
let constructed = 0;
class Hls {
constructor() {
constructed++;
}
static isSupported() {
return true;
}
}
const source = video();
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: () =>
new Promise((r) => {
resolve = r;
}),
});
playback.dispose();
resolve({ default: Hls });
await playback.ready;
assert.equal(constructed, 0);
assert.equal(source.src, '');
});
test('missing decoder uses honest failure fallback once', async () => {
let failures = 0;
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
loadHls: async () => {
throw new Error('unavailable');
},
onFailure: () => failures++,
});
await playback.ready;
playback.dispose();
assert.equal(failures, 1);
});
test('native HLS releases its client lease without response-header access', async () => {
const source = video();
source.canPlayType = () => 'probably';
const releases = [];
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async (url, init) => {
releases.push({ url, init });
},
});
await playback.ready;
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
const requested = source.src;
playback.dispose();
playback.dispose();
assert.equal(releases.length, 1);
assert.equal(releases[0].url, requested);
assert.equal(releases[0].init.method, 'DELETE');
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
source.loop = feedType === 'hls';
source.canPlayType = () => 'probably';
let imports = 0;
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
loadHls: async () => {
imports++;
return { default: { isSupported: () => false } };
},
fetchImpl: async () => {},
});
try {
await playback.ready;
assert.equal(source.loop, feedType !== 'hls');
assert.equal(imports, feedType === 'hls' ? 1 : 0);
} finally {
playback.dispose();
}
}
});
+447
View File
@@ -0,0 +1,447 @@
import * as Cesium from 'cesium';
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
registerPickOwner,
unregisterPickOwner,
} from '../../data/pickRegistry.js';
import {
createCycloneRendering,
coherentCycloneGeometry,
} from './rendering.js';
import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const CLASSIFICATION_NAMES = Object.freeze({
PTC: 'Potential tropical cyclone',
HU: 'Hurricane',
TS: 'Tropical storm',
TD: 'Tropical depression',
SS: 'Subtropical storm',
SD: 'Subtropical depression',
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
const number = (value, unit) =>
value === null ? 'Unavailable' : `${value} ${unit}`;
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
cesium = Cesium,
createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let viewer = null,
rendering = null,
snapshot = null,
request = null,
listener = null,
selectedId = null,
selectionIntent = 'auto',
navigationGeneration = 0,
clickHandler = null,
removeClickCapture = null;
let enabled = false,
loading = false,
error = null,
destroyed = false,
runNavigation = null;
const notify = () => listener?.();
const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function select(id) {
if (selectedId !== id) ++navigationGeneration;
selectedId = id;
rendering?.setSelection(id);
}
// Photorealistic 3D Tiles pick as tileset content without an entity id;
// that is empty map, the same as no pick at all on the globe.
const isSurfacePick = (picked) =>
!picked ||
(picked.id === undefined &&
(picked.content !== undefined ||
(typeof cesium.Cesium3DTileset === 'function' &&
picked.primitive instanceof cesium.Cesium3DTileset)));
function installSelection() {
if (
clickHandler ||
!viewer?.scene?.canvas ||
typeof cesium.ScreenSpaceEventHandler !== 'function'
)
return;
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler = owner;
// Snapshot before sibling bubble listeners can rebuild the overlay hit
// rectangles. A vessel selection does that synchronously in the same click.
const canvas = viewer.scene.canvas;
let capturedHit = null;
const overlayHit = (x, y) => {
const hit = hitTestOverlay(x, y);
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
};
const capture = (event) => {
capturedHit = null;
const point = event.changedTouches?.[0] || event;
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
return;
const rect = canvas.getBoundingClientRect();
const x = point.clientX - rect.left,
y = point.clientY - rect.top;
capturedHit = { x, y, ...overlayHit(x, y) };
};
const resetCapture = () => {
capturedHit = null;
};
const resetEvents = [
'pointerdown',
'mousedown',
'touchstart',
'pointercancel',
'touchcancel',
];
for (const type of resetEvents)
canvas.addEventListener?.(type, resetCapture, { capture: true });
const events = ['pointerup', 'mouseup', 'touchend'];
for (const type of events)
canvas.addEventListener?.(type, capture, { capture: true });
removeClickCapture = () => {
for (const type of events)
canvas.removeEventListener?.(type, capture, { capture: true });
for (const type of resetEvents)
canvas.removeEventListener?.(type, resetCapture, { capture: true });
capturedHit = null;
};
owner.setInputAction((click) => {
const nativeHit = capturedHit;
capturedHit = null;
// Ambient selection yields to draw tools and Director; it never claims
// the pointer, camera, or tracking state.
if (
!enabled ||
destroyed ||
clickHandler !== owner ||
!isPointerFree() ||
!click?.position
)
return;
// AIS cards paint above the globe on a pointer-events:none canvas. The
// vessel handler resolves this same topmost hit before cyclone geometry.
const captureMatches =
nativeHit &&
Math.abs(nativeHit.x - click.position.x) < 1 &&
Math.abs(nativeHit.y - click.position.y) < 1;
const hit = captureMatches
? nativeHit
: overlayHit(click.position.x, click.position.y);
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
// Storm cards and lead-hour labels paint on the same canvas; a click on
// one selects its storm. An id from a superseded advisory changes nothing.
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
const id = cycloneStormIdFromEntryId(hit.entryId);
if (id) layer.setParams({ stormId: id });
return;
}
const picked = viewer.scene.pick(click.position);
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeSelection() {
const owner = clickHandler;
clickHandler = null;
removeClickCapture?.();
removeClickCapture = null;
if (owner && !owner.isDestroyed?.()) owner.destroy();
}
const layer = {
id: 'weather-cyclones',
name: 'Cyclone advisories',
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({ viewer, cesium, overlayHost });
},
attachShellServices(services) {
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
if (!destroyed && !enabled) {
enabled = true;
registerPickOwner(
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
);
installSelection();
}
},
disable() {
enabled = false;
unregisterPickOwner('weather-cyclones');
removeSelection();
request?.abort();
request = null;
loading = false;
error = null;
snapshot = null;
selectedId = null;
selectionIntent = 'auto';
++navigationGeneration;
rendering?.clear();
},
async update(_viewer, { signal } = {}) {
if (!enabled || destroyed) return false;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const next = await feed.getSnapshot({ signal: controller.signal });
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (next.unavailable) {
// An expired advisory must not remain presented as current hazard context.
rendering.clear();
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || 'Cyclone advisories unavailable';
return true;
}
const applied = await rendering.setSnapshot(next, {
signal: controller.signal,
});
if (
!applied ||
!enabled ||
controller.signal.aborted ||
request !== controller
)
return false;
snapshot = next;
error = null;
if (!snapshot.storms.some((storm) => storm.id === selectedId))
select(
selectionIntent === 'cleared'
? null
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Cyclone advisories unavailable';
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
selectedId = null;
++navigationGeneration;
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = false;
notify();
}
}
},
setParams(params = {}) {
if (!enabled || destroyed) return;
if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared';
select(null);
notify();
} else if (
typeof params.stormId === 'string' &&
snapshot?.storms.some((storm) => storm.id === params.stormId)
) {
selectionIntent = 'user';
select(params.stormId);
notify();
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
const sphere = rendering.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
runNavigation(() => {
if (
!enabled ||
destroyed ||
generation !== navigationGeneration ||
selectedId !== storm.id
)
return;
return viewer.camera.flyToBoundingSphere(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
: 1.4,
});
});
}
}
if (params.advisory === true && storm?.advisoryUrl)
openLink(storm.advisoryUrl);
},
getRowControls() {
const storm = selected();
const empty =
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? 'Track and cone match this advisory'
: storm.geometryStatus === 'pending'
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
: 'Track/cone unavailable');
const status =
error ||
(snapshot?.stale
? 'Cached advisory · stale source'
: loading
? 'Loading advisories…'
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const detail = storm
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
: empty
? 'No active NHC/CPHC systems'
: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
: loading
? 'Loading advisories…'
: 'Advisories unavailable';
const controls = {
readout: true,
summary: {
label: 'Cyclones · NHC / CPHC',
coverage: 'Atlantic · E/C Pacific',
compact: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: 'Official advisory ↗',
href: storm.advisoryUrl,
},
]
: [],
settings: [],
lines: storm
? [
{
id: 'position',
text: `Position as of ${utc(storm.positionAt)}`,
muted: true,
},
{
id: 'intensity',
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
},
{ id: 'geometry', text: geometry, muted: true },
]
: [],
detail,
status,
units: 'kt',
},
list: {
ariaLabel: 'Active NHC and CPHC cyclone advisories',
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
},
chips: [],
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
]
: [],
info: storm
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
infoTitle:
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
};
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
const storm = selected();
return {
count: snapshot?.storms.length || 0,
lastUpdate: storm
? Date.parse(storm.issuedAt)
: snapshot?.fetchedAt || null,
loading,
error,
stale: Boolean(snapshot?.stale),
source: 'NOAA NHC / CPHC',
advisoryAt: storm?.issuedAt || null,
empty: Boolean(
snapshot && !snapshot.unavailable && !snapshot.storms.length,
),
};
},
getDiagnostics() {
return {
...rendering?.getDiagnostics(),
enabled,
loading,
requestPending: !!request,
selectionActive: clickHandler !== null,
selectedId,
selectionIntent,
timerActive: false,
};
},
destroy() {
if (destroyed) return;
layer.disable();
destroyed = true;
rendering?.destroy();
rendering = null;
viewer = null;
listener = null;
runNavigation = null;
},
};
return layer;
}
+673
View File
@@ -0,0 +1,673 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import {
claimPointer,
releasePointer,
isPointerFree,
} from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({
id,
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
});
const snapshot = (storms = [storm()]) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: 'Atlantic and eastern/central North Pacific',
reason: null,
});
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
const handlers = [],
pickedEntity = {},
foreignEntity = {};
let picked = { id: pickedEntity },
overlayHit = null,
overlayTests = 0,
picks = 0,
notices = 0;
const rendering = {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
ownsPickId: (id) => id === 'cyclone:ep162026:center',
getDiagnostics: () => ({}),
};
const cesium = {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor(canvas) {
assert.ok(canvas);
handlers.push(this);
this.destroyed = false;
}
setInputAction(callback, type) {
assert.equal(type, 'left');
this.click = callback;
}
destroy() {
this.destroyed = true;
}
isDestroyed() {
return this.destroyed;
}
},
};
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium,
createRendering: () => rendering,
hitTestOverlay: () => {
overlayTests++;
return overlayHit;
},
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const viewer = {
scene: {
canvas,
pick() {
picks++;
return picked;
},
},
trackedEntity: null,
camera: {
flyToBoundingSphere() {
assert.fail('Click must not move the camera');
},
},
};
layer.init(viewer);
layer.setRowControlsListener(() => notices++);
assert.equal(handlers.length, 0, 'disabled module installs no handler');
layer.enable();
layer.enable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
true,
'flight tracking recognizes the cyclone pick and leaves the camera alone',
);
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
false,
'registration recognizes exact current IDs, not a prefix',
);
assert.equal(handlers.length, 1, 'enable is idempotent');
await layer.update();
const click = { position: { x: 10, y: 20 } };
const owner = claimPointer('director');
assert.ok(owner);
try {
handlers[0].click(click);
assert.equal(picks, 0, 'claimed pointer is checked before picking');
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
} finally {
releasePointer(owner);
}
picked = { id: foreignEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
picked = { primitive: {} };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
picked = { id: pickedEntity };
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
const beforeCard = picks;
handlers[0].click(click);
assert.equal(
picks,
beforeCard,
'foreground AIS card wins before scene picking',
);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A native capture runs before an earlier sibling handler clears hit rects.
const up = new Event('pointerup');
Object.assign(up, { clientX: 110, clientY: 220 });
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'captured AIS hit survives synchronous sibling overlay rebuild',
);
// A captured background hit remains background even if a sibling adds a card.
canvas.dispatchEvent(up);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
// A new gesture clears an unconsumed release snapshot.
canvas.dispatchEvent(up);
canvas.dispatchEvent(new Event('pointerdown'));
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click({ position: { x: 15, y: 25 } });
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
'a different click cannot reuse the captured card',
);
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
const before = notices;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.match(layer.getRowControls().summary.detail, /Another system/);
assert.equal(layer.getRowControls().list.items[1].active, true);
assert.ok(notices > before);
assert.equal(viewer.trackedEntity, null);
picked = undefined;
overlayHit = { sourceId: 'ais-live-vessels' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
picked = { id: pickedEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
picked = { id: 'flight:abc', primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
"another layer's pick leaves the selection alone",
);
picked = { content: {}, primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
null,
'3D Tiles surface content is empty map',
);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(
isPointerFree(),
true,
'ambient selection never claims the pointer',
);
layer.disable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[0].destroyed, true);
assert.equal(layer.getDiagnostics().selectionActive, false);
const afterDisable = picks;
const afterDisableOverlay = overlayTests;
canvas.dispatchEvent(up);
assert.equal(
overlayTests,
afterDisableOverlay,
'disable removes native capture listeners',
);
handlers[0].click(click);
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
layer.enable();
await layer.update();
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
handlers[0].click(click);
assert.equal(
picks,
afterDisable,
'superseded handler remains inert after re-enable',
);
assert.equal(handlers.length, 2);
layer.destroy();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[1].destroyed, true);
});
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
const handlers = [];
let picks = 0,
overlayHit = null,
hostArgument;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium: {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor() {
handlers.push(this);
}
setInputAction(callback) {
this.click = callback;
}
destroy() {}
},
},
overlayHost: 'host',
createRendering: ({ overlayHost }) => {
hostArgument = overlayHost;
return {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: () => null,
ownsPickId: () => false,
getDiagnostics: () => ({}),
};
},
hitTestOverlay: () => overlayHit,
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
layer.init({
scene: {
canvas,
pick() {
picks++;
return { content: {} };
},
},
});
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const click = { position: { x: 10, y: 20 } };
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A card from a superseded advisory is not empty map.
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// The native release snapshot carries the card identity too.
const up = new Event('pointerup');
Object.assign(up, { clientX: 10, clientY: 20 });
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(picks, 0, 'card clicks never fall through to the scene');
handlers[0].click(click);
assert.equal(picks, 1);
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
layer.destroy();
});
function harness(
feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {},
) {
const applied = [],
navigation = [],
opened = [];
let cleared = 0,
destroyed = 0,
selection = null;
const rendering = {
async setSnapshot(value) {
applied.push(value);
return true;
},
setSelection(id) {
selection = id;
},
getFocusSphere: () => ({ radius: 500000 }),
clear() {
cleared++;
},
destroy() {
destroyed++;
},
getDiagnostics: () => ({
entities: applied.at(-1)?.storms.length || 0,
timerActive: false,
}),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
});
const viewer = {
camera: {
flyToBoundingSphere: (sphere, options) =>
navigation.push({ sphere, options }),
},
};
layer.init(viewer);
layer.attachShellServices({
runNavigation: (fn) => {
navigation.push('claimed');
return fn();
},
});
return {
layer,
applied,
navigation,
opened,
get cleared() {
return cleared;
},
get destroyed() {
return destroyed;
},
get selection() {
return selection;
},
};
}
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm('ep162026')]);
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm(), storm('ep162026')]);
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep162026' });
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
assert.equal(h.navigation.length, 0);
next = snapshot([storm()]);
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
for (const clear of [{ stormId: null }, { clear: true }]) {
h.layer.setParams({ stormId: 'ep152026' });
h.layer.setParams(clear);
assert.equal(h.selection, null);
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
await h.layer.update();
assert.equal(h.selection, null);
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
}
h.layer.disable();
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
});
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
const staged = deferred();
const staging = deferred();
let calls = 0;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot() },
createRendering: () => ({
setSnapshot() {
if (++calls === 1) return true;
staging.resolve();
return staged.promise;
},
setSelection() {},
getDiagnostics: () => ({}),
clear() {},
destroy() {},
}),
});
layer.init({});
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const pending = layer.update();
await staging.promise;
layer.setParams({ clear: true });
staged.resolve(true);
await pending;
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
layer.destroy();
});
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
if (change === 'select') {
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep152026' });
} else if (change === 'clear') h.layer.setParams({ stormId: null });
else if (change === 'fallback') {
next = snapshot([storm('ep162026')]);
await h.layer.update();
} else h.layer[change]();
callbacks.shift()();
assert.equal(h.navigation.length, 0, change);
h.layer.destroy();
}
});
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
const h = harness();
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
h.layer.setParams({ stormId: 'ep152026', focus: true });
await h.layer.update();
callbacks[0]();
assert.equal(h.navigation.length, 0);
callbacks[1]();
assert.equal(h.navigation.length, 1);
assert.equal(h.navigation[0].options.duration, 1.4);
h.layer.destroy();
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
const record = { ...storm(), classification: code };
const h = harness({ getSnapshot: async () => snapshot([record]) });
try {
h.layer.enable();
await h.layer.update();
const controls = h.layer.getRowControls();
assert.ok(controls.summary.detail.includes(` · ${label} · `));
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
assert.equal(record.classification, code);
assert.equal(h.applied[0].storms[0].classification, code);
} finally {
h.layer.destroy();
}
}
});
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
const h = harness(
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
{ reducedMotion: true },
);
h.layer.enable();
assert.equal(await h.layer.update(), true);
const controls = h.layer.getRowControls();
assert.equal(controls.readout, true);
assert.deepEqual(controls.chips, []);
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
assert.equal(controls.summary.lines.length, 3);
assert.deepEqual(controls.summary.settings, []);
assert.deepEqual(controls.summary.actions, [
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
]);
assert.match(controls.summary.compact, /2 active storms · .* selected/);
assert.equal(controls.summary.sections, undefined);
assert.match(controls.list.ariaLabel, /NHC/);
assert.equal(controls.list.items.length, 2);
assert.equal(controls.list.items[0].active, true);
assert.match(controls.info, /awaiting advisory 10/);
assert.equal(
controls.info.split('Track/cone awaiting advisory 10').length - 1,
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16 03:00 UTC/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
assert.equal(
controls.chips.some((chip) => chip.id === 'focus'),
false,
);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.selection, 'ep162026');
assert.equal(h.navigation[0], 'claimed');
assert.equal(h.navigation[1].options.duration, 0);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.navigation.length, 4, 'selected row focuses again');
h.layer.setParams({ advisory: true });
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
h.layer.disable();
h.layer.setParams({ focus: true, advisory: true });
assert.equal(h.navigation.length, 4);
assert.equal(h.opened.length, 1);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.layer.getDiagnostics().timerActive, false);
h.layer.destroy();
assert.equal(h.destroyed, 1);
});
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
let next = snapshot([]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().empty, true);
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
next = { ...snapshot(), stale: true };
await h.layer.update();
assert.match(h.layer.getRowControls().summary.status, /stale/);
assert.match(
h.layer.getRowControls().info,
/Track\/cone awaiting advisory 10/,
);
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
next = {
...snapshot([]),
unavailable: true,
reason: 'Cyclone data unavailable',
};
await h.layer.update();
assert.equal(h.layer.getStats().empty, false);
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
const first = deferred(),
second = deferred();
const signals = [];
let calls = 0;
const h = harness({
getSnapshot: ({ signal }) => {
signals.push(signal);
return (++calls === 1 ? first : second).promise;
},
});
h.layer.enable();
const old = h.layer.update(),
latest = h.layer.update();
assert.equal(signals[0].aborted, true);
second.resolve(snapshot());
assert.equal(await latest, true);
first.resolve(snapshot([storm('ep162026')]));
assert.equal(await old, false);
assert.equal(h.applied.length, 1);
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
const delayed = deferred();
const disabled = harness({ getSnapshot: () => delayed.promise });
disabled.layer.enable();
const work = disabled.layer.update();
disabled.layer.disable();
delayed.resolve(snapshot());
assert.equal(await work, false);
assert.equal(disabled.applied.length, 0);
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
disabled.layer.destroy();
});
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => {
if (fail) throw new Error('Network unavailable');
return snapshot();
},
});
h.layer.enable();
assert.equal(
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
false,
);
assert.equal(h.applied.length, 0);
await h.layer.update();
fail = true;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
assert.match(h.layer.getStats().error, /Network/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
+180
View File
@@ -0,0 +1,180 @@
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../../overlays/worldOverlay.js';
/**
* @module cyclones/labels
* @description Storm cards and lead-hour labels on the shared world-overlay
* host. The renderer supplies the anchors its points are drawn and
* horizon-culled at, plus the selection; the host owns projection, the same
* ellipsoid horizon test, collisions, fades and paint.
*/
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
/** Lead-hour labels show within 4,000 km of the camera. */
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
/** Advisory centre and forecast track; single source for points and cards. */
export const CYCLONE_ACCENT = '#7fe6ed';
/** Selected advisory centre. */
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
/** Point sizes the cards keep clear of. */
export const CYCLONE_MARKER_PX = Object.freeze({
storm: 9,
selected: 12,
forecast: 5,
});
const STORM_PRIORITY = 1000;
const LEAD_PRIORITY = 500;
const CYCLONE_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
function markerClearance(markerPx) {
const gapPx = Math.max(12, markerPx + 8);
return {
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
viewportMargin: 4,
};
}
const SHARED_CARD = Object.freeze({
cardStyle: 'tactical',
collisionGroup: 'ambient-card',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// A click on a card selects its storm.
interactive: true,
});
/**
* One card per storm: name, then the source classification code.
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
),
id: `storm:${storm.id}`,
position: storm.position,
variant: selected ? 'selected' : 'card',
title: storm.name,
details: storm.classification ? [storm.classification] : [],
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
selected,
protected: selected,
priority: selected
? Number.MAX_SAFE_INTEGER
: STORM_PRIORITY + (Number(storm.windKt) || 0),
maxDistance: Number.POSITIVE_INFINITY,
};
}
/**
* Lead-hour label at one forecast point of the selected storm. Earlier lead
* times win collisions.
* @param {string} stormId
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
id: `lead:${stormId}:${point.tauHours}`,
position: point.position,
variant: 'card',
title: `${point.tauHours} h`,
details: [],
accent: CYCLONE_ACCENT,
selected: false,
protected: false,
priority: LEAD_PRIORITY - point.tauHours,
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
// Hard cutoff at the limit, without a fade.
distanceFadeStartRatio: 1,
};
}
/**
* Storm cards for every storm and lead-hour labels for the selected one.
* @param {Array<object>} storms Renderer anchors with `forecasts`.
* @param {?string} selectedId
* @returns {Array<object>}
*/
export function cycloneOverlayEntries(storms, selectedId) {
const entries = [];
for (const storm of storms) {
const selected = storm.id === selectedId;
entries.push(cycloneStormEntry(storm, selected));
if (!selected) continue;
for (const point of storm.forecasts || [])
entries.push(cycloneLeadEntry(storm.id, point));
}
return entries;
}
/** Storm id behind a published card or lead-hour label id, else null. */
export function cycloneStormIdFromEntryId(entryId) {
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
return match ? match[1] : null;
}
/**
* Publish the renderer's committed anchors and selection to the overlay host.
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
*/
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
let storms = [];
let selectedId = null;
let published = false;
function publish() {
const entries = cycloneOverlayEntries(storms, selectedId);
let ambient = 0;
for (const entry of entries) if (!entry.protected) ambient++;
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
moving: false,
});
published = true;
}
return {
/** Replace every anchor after a snapshot commits. */
setSnapshot(nextStorms, nextSelectedId = selectedId) {
storms = nextStorms;
selectedId = nextSelectedId;
publish();
},
/** Republish only when the selection changes a published snapshot. */
setSelection(id) {
if (id === selectedId) return;
selectedId = id;
if (published) publish();
},
clear() {
storms = [];
selectedId = null;
if (!published) return;
published = false;
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
},
};
}
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
CYCLONE_OVERLAY_COHORT_LIMIT,
CYCLONE_OVERLAY_SOURCE_ID,
createCycloneLabels,
cycloneOverlayEntries,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
normalizeOverlayEntry,
paintLaneForOverlayEntry,
WORLD_OVERLAY_PAINT_LANES,
} from '../../overlays/worldOverlay.js';
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
const anchor = (longitude, latitude) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
const storms = () => [
{
id: 'al062026',
name: 'Fay',
classification: 'TS',
windKt: 50,
position: anchor(-60, 20),
forecasts: [
{ tauHours: 12, position: anchor(-61, 21) },
{ tauHours: 24, position: anchor(-62, 22) },
],
},
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
windKt: null,
position: anchor(-125, 15),
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
},
];
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
const input = storms();
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
assert.deepEqual(
[...entries.keys()],
[
'storm:al062026',
'lead:al062026:12',
'lead:al062026:24',
'storm:ep152026',
],
'lead-hour labels belong to the selected storm only',
);
const selected = entries.get('storm:al062026');
const ambient = entries.get('storm:ep152026');
for (const card of [selected, ambient]) {
assert.equal(card.cardStyle, 'tactical');
assert.equal(card.collisionGroup, 'ambient-card');
assert.equal(card.verticalOnly, true);
assert.equal(card.horizonCull, true);
assert.equal(card.edgeFade, 'keyhole');
assert.equal(card.interactive, true);
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
}
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
assert.equal(selected.title, 'Fay');
assert.deepEqual(selected.details, ['TS']);
assert.equal(selected.variant, 'selected');
assert.equal(selected.selected, true);
assert.equal(selected.protected, true);
assert.equal(selected.accent, '#ffe19a');
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
assert.equal(selected.gapPx, 20);
assert.equal(selected.leaderOffsetPx, 14);
assert.equal(ambient.title, 'Fifteen-E');
assert.deepEqual(ambient.details, ['PTC']);
assert.equal(ambient.variant, 'card');
assert.equal(ambient.protected, false);
assert.equal(ambient.accent, '#7fe6ed');
assert.equal(ambient.gapPx, 17);
assert.equal(ambient.leaderOffsetPx, 11);
const lead = entries.get('lead:al062026:24');
assert.equal(lead.position, input[0].forecasts[1].position);
assert.equal(lead.title, '24 h');
assert.deepEqual(lead.details, []);
assert.equal(lead.cardStyle, 'tactical');
assert.equal(lead.collisionGroup, 'ambient-card');
assert.equal(lead.protected, false);
assert.equal(lead.interactive, true);
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
assert.equal(lead.gapPx, 13);
assert.equal(lead.leaderOffsetPx, 7);
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
const none = cycloneOverlayEntries(input, null);
assert.deepEqual(
none.map((entry) => [entry.id, entry.variant]),
[
['storm:al062026', 'card'],
['storm:ep152026', 'card'],
],
);
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
});
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
);
const lane = (entry) =>
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
const [selected, lead] = entries;
assert.equal(lane(selected), 'selected');
assert.equal(lane(lead), 'ambient-card');
assert.equal(lane(entries.at(-1)), 'ambient-card');
for (const entry of entries) {
assert.equal(entry.collisionGroup, 'ambient-card');
assert.equal(entry.cardStyle, 'tactical');
assert.equal(entry.horizonCull, true);
}
const range = {
minDistance: lead.minDistance,
maxDistance: lead.maxDistance,
fadeStartRatio: lead.distanceFadeStartRatio,
};
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
assert.equal(
distanceFade(40_000_000, {
minDistance: selected.minDistance,
maxDistance: selected.maxDistance,
fadeStartRatio: selected.distanceFadeStartRatio,
}),
1,
'storm cards have no distance limit',
);
});
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
const calls = [];
const host = {
setEntries: (...args) => calls.push(['entries', ...args]),
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
};
const labels = createCycloneLabels({ host });
labels.setSelection('al062026');
labels.clear();
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
labels.setSnapshot(storms(), 'al062026');
assert.deepEqual(
calls.map(([kind, source]) => [kind, source]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID],
['entries', CYCLONE_OVERLAY_SOURCE_ID],
],
);
assert.equal(calls[0][2], true);
const [, , entries, options] = calls[1];
assert.equal(entries.length, 4);
assert.deepEqual(options, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: 3,
moving: false,
});
calls.length = 0;
labels.setSelection('al062026');
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
labels.setSelection('ep152026');
const published = calls.find(([kind]) => kind === 'entries')[2];
assert.deepEqual(
published.map((entry) => entry.id),
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
);
assert.equal(published[1].variant, 'selected');
calls.length = 0;
labels.clear();
assert.deepEqual(calls, [
['clear', CYCLONE_OVERLAY_SOURCE_ID],
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
]);
labels.clear();
labels.setSelection('al062026');
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
});
test('storm identity resolves from card and lead-hour ids only', () => {
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
});
+314
View File
@@ -0,0 +1,314 @@
import {
CYCLONE_ACCENT,
CYCLONE_MARKER_PX,
CYCLONE_SELECTED_ACCENT,
createCycloneLabels,
} from './labels.js';
/** Only geometry from the displayed status advisory is eligible for rendering. */
export function coherentCycloneGeometry(storm) {
return (
storm.geometryStatus === 'current' &&
storm.geometryAdvisoryNumber === storm.advisoryNumber
);
}
/**
* Static Cesium entities with owned horizon culling; no timers or clock.
* Storm names and lead hours are published to the shared overlay host at the
* same anchors the points use.
*/
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
const labels = createCycloneLabels({ host: overlayHost });
let source = null,
generation = 0,
selected = null,
destroyed = false;
let centers = new Map(),
spheres = new Map();
let entityStorms = new WeakMap();
let entityIds = new Set();
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
let horizonStorms = [],
removePreRender = null,
pointOccluder = null,
sphereOccluder = null;
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
const white = C.Color.WHITE;
const render = () => {
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
};
function cullHorizon() {
pointOccluder.cameraPosition = viewer.camera.positionWC;
sphereOccluder.cameraPosition = viewer.camera.positionWC;
let changed = false;
for (let i = 0; i < horizonStorms.length; i++) {
const storm = horizonStorms[i];
for (let j = 0; j < storm.points.length; j++) {
const { entity, position } = storm.points[j];
const show = pointOccluder.isPointVisible(position);
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
if (!storm.shapes.length) continue;
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
for (let j = 0; j < storm.shapes.length; j++) {
const entity = storm.shapes[j];
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
}
if (changed) render();
}
function syncHorizonListener() {
if (!horizonStorms.length) {
removePreRender?.();
removePreRender = null;
return;
}
if (removePreRender) return;
pointOccluder ||= new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
// conservatively culls extents, retaining partially visible tracks/cones.
sphereOccluder ||= new C.Occluder(
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
viewer.camera.positionWC,
);
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
}
function remove(value) {
if (!value) return;
if (!viewer.dataSources.isDestroyed?.())
viewer.dataSources.remove(value, true);
value.entities.removeAll();
}
function select(id) {
selected = id;
for (const [stormId, entity] of centers) {
entity.point.color = stormId === id ? gold : blue;
entity.point.pixelSize =
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
}
labels.setSelection(id);
render();
}
return {
async setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
if (destroyed) return false;
const owner = ++generation;
const next = new C.CustomDataSource('weather-cyclones');
const nextCenters = new Map(),
nextSpheres = new Map();
const nextEntityStorms = new WeakMap();
const nextEntityIds = new Set();
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
const nextHorizonStorms = [];
const nextLabels = [];
const position = ({ longitude, latitude }) =>
C.Cartesian3.fromDegrees(longitude, latitude, 0);
const coordinate = (pair) =>
position({ longitude: pair[0], latitude: pair[1] });
try {
for (const storm of snapshot.storms) {
const horizon = { points: [], shapes: [], sphere: null };
const addEntity = (options) => {
const entity = next.entities.add(options);
if (options.position)
horizon.points.push({ entity, position: options.position });
else horizon.shapes.push(entity);
nextEntityStorms.set(entity, storm.id);
nextEntityIds.add(entity.id);
return entity;
};
const center = position(storm.position),
extent = [center];
const entity = addEntity({
id: `cyclone:${storm.id}:center`,
name: storm.name,
position: center,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.storm,
color: blue,
outlineColor: C.Color.BLACK,
outlineWidth: 2,
},
});
nextCenters.set(storm.id, entity);
const label = {
id: storm.id,
name: storm.name,
classification: storm.classification,
windKt: storm.windKt,
position: center,
forecasts: [],
};
nextLabels.push(label);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: storm.track?.coordinates || [];
lines.forEach((line, index) => {
const positions = line.map(coordinate);
extent.push(...positions);
addEntity({
id: `cyclone:${storm.id}:track:${index}`,
polyline: {
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
positions,
width: 2.5,
material: blue,
arcType: C.ArcType.GEODESIC,
},
});
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: storm.cone?.coordinates || [];
polygons.forEach((rings, index) => {
// Each exterior retains its own interior holes. Native geographic
// tessellation handles the antimeridian; never flatten rings.
const exterior = rings[0].map(coordinate);
extent.push(...exterior);
const holes = rings
.slice(1)
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
addEntity({
id: `cyclone:${storm.id}:cone:${index}`,
polygon: {
hierarchy: new C.PolygonHierarchy(exterior, holes),
classificationType: C.ClassificationType.BOTH,
material: blue.withAlpha(0.16),
arcType: C.ArcType.GEODESIC,
},
});
[exterior, ...holes.map((hole) => hole.positions)].forEach(
(positions, ringIndex) => {
addEntity({
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
polyline: {
positions,
width: 1,
material: blue.withAlpha(0.55),
arcType: C.ArcType.GEODESIC,
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
},
});
},
);
nextCounts.cones++;
});
for (const [index, point] of storm.forecastPoints.entries()) {
if (point.tauHours === 0) continue;
const p = position(point.position);
extent.push(p);
addEntity({
id: `cyclone:${storm.id}:forecast:${index}`,
position: p,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.forecast,
color: white,
outlineColor: C.Color.BLACK,
outlineWidth: 1,
},
});
label.forecasts.push({ tauHours: point.tauHours, position: p });
nextCounts.forecastPoints++;
}
}
const sphere = C.BoundingSphere.fromPoints(extent);
// A status-only point still has a useful regional camera destination.
sphere.radius = Math.max(sphere.radius, 500_000);
nextSpheres.set(storm.id, sphere);
horizon.sphere = sphere;
nextHorizonStorms.push(horizon);
}
await viewer.dataSources.add(next);
if (destroyed || generation !== owner || signal?.aborted) {
remove(next);
return false;
}
remove(source);
source = next;
centers = nextCenters;
spheres = nextSpheres;
entityStorms = nextEntityStorms;
entityIds = nextEntityIds;
counts = nextCounts;
horizonStorms = nextHorizonStorms;
syncHorizonListener();
labels.setSnapshot(nextLabels, selected);
select(selected);
return true;
} catch (error) {
remove(next);
if (signal?.aborted || generation !== owner || destroyed) return false;
throw error;
}
},
setSelection: select,
ownsPickId(id) {
return source !== null && typeof id === 'string' && entityIds.has(id);
},
pickStorm(picked) {
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
// cannot establish ownership, especially after an advisory replacement.
const entity = picked?.id;
return source && entity && typeof entity === 'object'
? entityStorms.get(entity) || null
: null;
},
getFocusSphere(id) {
return spheres.get(id) || null;
},
clear() {
++generation;
horizonStorms = [];
syncHorizonListener();
pointOccluder = null;
sphereOccluder = null;
remove(source);
source = null;
labels.clear();
centers.clear();
spheres.clear();
entityStorms = new WeakMap();
entityIds.clear();
selected = null;
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
render();
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
},
getDiagnostics() {
return {
...counts,
dataSources: Number(!!source),
entities: source?.entities.values.length || 0,
selectedId: selected,
timerActive: false,
};
},
};
}
+669
View File
@@ -0,0 +1,669 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCycloneRendering } from './rendering.js';
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
/** Records what the renderer publishes to the shared overlay host. */
function overlayRecorder() {
const calls = [];
return {
calls,
host: {
setEntries: (source, entries, options) =>
calls.push({ kind: 'entries', source, entries, options }),
setVisible: (source, visible) =>
calls.push({ kind: 'visible', source, visible }),
clearSource: (source) => calls.push({ kind: 'clear', source }),
},
get publishes() {
return calls.filter(({ kind }) => kind === 'entries').length;
},
published() {
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
},
entry(id) {
return this.published().find((entry) => entry.id === id) || null;
},
};
}
function harness({ deferred = false } = {}) {
const sources = [],
completions = [],
pointOccluders = [],
sphereOccluders = [];
const listeners = new Set();
const visibility = {
points: new Map(),
spheres: new Map(),
pointCalls: [],
sphereCalls: [],
writes: 0,
};
const color = (value) => ({
value,
withAlpha: (alpha) => ({ value, alpha }),
});
const cesium = {
Color: {
fromCssColorString: color,
WHITE: color('white'),
BLACK: color('black'),
},
Cartesian2: class {
constructor(x, y) {
this.x = x;
this.y = y;
}
},
Cartesian3: {
ZERO: { x: 0, y: 0, z: 0 },
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
},
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
EllipsoidalOccluder: class {
constructor(ellipsoid, cameraPosition) {
this.ellipsoid = ellipsoid;
this.cameraPosition = cameraPosition;
pointOccluders.push(this);
}
isPointVisible(position) {
visibility.pointCalls.push(position);
return visibility.points.get(position) ?? true;
}
},
Occluder: class {
constructor(sphere, cameraPosition) {
this.sphere = sphere;
this.cameraPosition = cameraPosition;
sphereOccluders.push(this);
}
isBoundingSphereVisible(sphere) {
visibility.sphereCalls.push(sphere);
return visibility.spheres.get(sphere) ?? true;
}
},
PolygonHierarchy: class {
constructor(positions, holes = []) {
this.positions = positions;
this.holes = holes;
}
},
BoundingSphere: class {
constructor(center, radius) {
this.center = center;
this.radius = radius;
}
static fromPoints(points) {
return { points, radius: 10 };
}
},
LabelStyle: { FILL_AND_OUTLINE: 1 },
HorizontalOrigin: { LEFT: 1 },
ArcType: { GEODESIC: 1 },
HeightReference: Cesium.HeightReference,
ClassificationType: Cesium.ClassificationType,
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
CustomDataSource: class {
constructor() {
const values = [];
this.entities = {
values,
add: (value) => {
let show = true;
Object.defineProperty(value, 'show', {
get: () => show,
set: (next) => {
show = next;
visibility.writes++;
},
});
values.push(value);
return value;
},
removeAll: () => {
values.length = 0;
},
};
}
},
};
let renders = 0;
const overlay = overlayRecorder();
const viewer = {
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
scene: {
requestRender: () => renders++,
preRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
dataSources: {
add(value) {
if (deferred)
return new Promise((resolve) =>
completions.push(() => {
sources.push(value);
resolve(value);
}),
);
sources.push(value);
return Promise.resolve(value);
},
remove(value) {
const i = sources.indexOf(value);
if (i >= 0) sources.splice(i, 1);
},
},
};
return {
rendering: createCycloneRendering({
viewer,
cesium,
overlayHost: overlay.host,
}),
overlay,
sources,
completions,
viewer,
cesium,
visibility,
pointOccluders,
sphereOccluders,
listeners,
frame() {
for (const listener of listeners) listener();
},
get renders() {
return renders;
},
};
}
const storm = () => ({
id: 'ep152026',
name: 'Fifteen-E',
advisoryNumber: '10',
geometryAdvisoryNumber: '10',
geometryStatus: 'current',
position: { longitude: 179, latitude: 15 },
forecastPoints: [
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
],
track: {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
],
},
cone: {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
},
});
test('horizon culling updates only changed entities and keeps selection independent', async () => {
const h = harness();
await h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near' }],
});
const entities = h.sources[0].entities.values;
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
const sphere = h.rendering.getFocusSphere('ep152026');
for (const entity of far)
if (entity.position) h.visibility.points.set(entity.position, false);
h.visibility.spheres.set(sphere, false);
h.rendering.setSelection('ep152026');
const publishes = h.overlay.publishes;
const before = h.renders;
h.frame();
assert.ok(far.every((e) => e.show === false));
assert.ok(near.every((e) => e.show === true));
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
forecast.position,
'the host culls labels at the same anchors the points use',
);
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
assert.equal(h.visibility.pointCalls.length, 4);
assert.deepEqual(h.visibility.sphereCalls, [
sphere,
h.rendering.getFocusSphere('near'),
]);
h.frame();
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
// A visible portion of the extent keeps all tracks/cones shown even when
// the centre and an individual forecast point remain beyond the horizon.
h.visibility.spheres.set(sphere, true);
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
h.frame();
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
assert.equal(h.renders, before + 2);
assert.equal(h.pointOccluders.length, 1);
assert.equal(h.sphereOccluders.length, 1);
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
h.rendering.setSelection('near');
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(
h.overlay.entry('lead:near:12').position,
near.find((e) => e.id.endsWith('forecast:0')).position,
);
h.visibility.points.set(forecast.position, true);
const beforeReveal = h.renders;
const revealPublishes = h.overlay.publishes;
h.frame();
assert.equal(forecast.show, true);
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(h.overlay.publishes, revealPublishes);
assert.equal(far[0].show, false);
assert.equal(h.renders, beforeReveal + 1);
h.rendering.destroy();
});
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
const h = harness();
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
const old = [...h.sources[0].entities.values];
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.frame();
assert.ok(
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
assert.equal(h.listeners.size, 0);
const before = h.renders;
h.frame();
assert.equal(h.renders, before);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.rendering.destroy();
h.rendering.destroy();
assert.equal(h.listeners.size, 0);
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
assert.equal(h.listeners.size, 0);
});
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
for (const method of ['clear', 'destroy']) {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 0);
h.rendering[method]();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.listeners.size, 0);
}
});
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
for (const globeShow of [true, false]) {
const sources = [];
const viewer = {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
scene: {
globe: { show: globeShow },
preRender: new Cesium.Event(),
requestRender() {},
},
dataSources: {
async add(source) {
sources.push(source);
},
remove() {},
},
};
const overlay = overlayRecorder();
const rendering = createCycloneRendering({
viewer,
cesium: Cesium,
overlayHost: overlay.host,
});
const at = (id, longitude) => ({
...storm(),
id,
position: { longitude, latitude: 0 },
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
track: {
type: 'LineString',
coordinates: [
[longitude, 0],
[longitude, 1],
],
},
cone: {
type: 'Polygon',
coordinates: [
[
[longitude, 0],
[longitude + 1, 0],
[longitude, 1],
[longitude, 0],
],
],
},
});
await rendering.setSnapshot({
storms: [at('near', 0), at('far', 180), at('limb', 5)],
});
rendering.setSelection('near');
viewer.scene.preRender.raiseEvent();
const entities = sources[0].entities.values;
for (const entity of entities.filter((e) => e.position)) {
assert.equal(
entity.point.heightReference.getValue(),
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
assert.equal(entity.label, undefined, 'no Cesium label graphics');
const published = overlay.entry(
entity.id.includes(':forecast:')
? `lead:${entity.id.split(':')[1]}:24`
: `storm:${entity.id.split(':')[1]}`,
);
if (entity.id.startsWith('cyclone:near:')) {
assert.ok(
Cesium.Cartesian3.equals(
published.position,
entity.position.getValue(),
),
);
assert.equal(published.horizonCull, true);
}
if (entity.id === 'cyclone:near:forecast:0')
assert.equal(published.maxDistance, 4_000_000);
}
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:near:'))
.every((e) => e.show),
);
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:far:'))
.every((e) => !e.show),
);
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
rendering.destroy();
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
}
});
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const oldEntities = [...h.sources[0].entities.values];
assert.equal(oldEntities.length, 8);
for (const entity of oldEntities) {
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
assert.equal(h.rendering.ownsPickId(entity.id), true);
assert.equal(
h.rendering.pickStorm({ id: { ...entity } }),
null,
'copied ID is not ownership',
);
assert.equal(
h.rendering.pickStorm({ id: entity.id }),
null,
'string prefix is not ownership',
);
}
assert.equal(h.rendering.pickStorm(undefined), null);
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
await h.rendering.setSnapshot({ storms: [storm()] });
for (const entity of oldEntities)
assert.equal(h.rendering.pickStorm({ id: entity }), null);
const current = h.sources[0].entities.values[0];
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
h.rendering.clear();
assert.equal(h.rendering.pickStorm({ id: current }), null);
assert.equal(h.rendering.ownsPickId(current.id), false);
h.rendering.destroy();
});
test('registry IDs switch only when the next coherent data source commits', async () => {
const h = harness({ deferred: true });
const first = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
h.completions.shift()();
await first;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
const next = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'ep162026' }],
});
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
h.completions.shift()();
await next;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
h.rendering.destroy();
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
});
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const entities = h.sources[0].entities.values;
const cones = entities.filter((e) => e.polygon);
assert.equal(cones.length, 2);
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
for (const cone of cones) {
assert.equal(
cone.polygon.classificationType,
h.cesium.ClassificationType.BOTH,
);
assert.equal(cone.polygon.height, undefined);
assert.equal(cone.polygon.extrudedHeight, undefined);
assert.equal(cone.polygon.outline, undefined);
}
const outlines = entities.filter((e) => e.id.includes(':outline:'));
assert.equal(outlines.length, 3);
assert.deepEqual(
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
storm().cone.coordinates.flat(),
);
for (const outline of outlines) {
assert.deepEqual(outline.polyline.material, {
value: '#7fe6ed',
alpha: 0.55,
});
}
for (const { polyline } of entities.filter((e) => e.polyline)) {
assert.equal(polyline.clampToGround, true);
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
assert.ok(polyline.positions.every((p) => p.height === 0));
}
assert.equal(
entities.find((e) => e.polyline).polyline.positions[1].lon,
-179,
);
assert.deepEqual(h.rendering.getDiagnostics(), {
storms: 1,
tracks: 1,
cones: 2,
forecastPoints: 1,
dataSources: 1,
entities: 8,
selectedId: null,
timerActive: false,
});
h.rendering.setSelection('ep152026');
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
entities.find((e) => e.id.endsWith('forecast:0')).position,
);
assert.ok(entities.every((e) => e.label === undefined));
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
h.rendering.destroy();
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().entities, 0);
});
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
for (const changed of [
{ geometryStatus: 'pending' },
{ geometryAdvisoryNumber: '9' },
]) {
const h = harness();
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.rendering.getDiagnostics().tracks, 0);
h.rendering.destroy();
}
});
test('a data-source add settling after disable is removed, without a new owner', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
});
test('superseded asynchronous additions cannot replace newer geometry', async () => {
const h = harness({ deferred: true });
const old = h.rendering.setSnapshot({ storms: [storm()] });
const latest = h.rendering.setSnapshot({ storms: [] });
h.completions[1]();
assert.equal(await latest, true);
h.completions[0]();
assert.equal(await old, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 0);
h.rendering.destroy();
});
test('aborting a pending add retains the prior complete source', async () => {
const h = harness({ deferred: true });
const initial = h.rendering.setSnapshot({ storms: [storm()] });
h.completions.shift()();
await initial;
const controller = new AbortController();
const update = h.rendering.setSnapshot(
{ storms: [] },
{ signal: controller.signal },
);
controller.abort();
h.completions.shift()();
assert.equal(await update, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 1);
h.rendering.destroy();
});
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
});
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
h.completions.shift()();
assert.equal(await pending, true);
const entities = h.sources[0].entities.values;
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
assert.deepEqual(
h.overlay.calls
.slice(0, 2)
.map(({ kind, source, visible }) => [kind, source, visible]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
],
);
assert.deepEqual(
h.overlay.published().map((entry) => entry.id),
['storm:ep152026', 'storm:near'],
'no selection, no lead-hour labels',
);
assert.equal(h.overlay.entry('storm:near').title, 'Near');
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
h.rendering.setSelection('near');
assert.equal(center('near').point.pixelSize, 12);
assert.equal(center('ep152026').point.pixelSize, 9);
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
assert.ok(h.overlay.entry('lead:near:12'));
const publishes = h.overlay.publishes;
h.rendering.setSelection('near');
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
// A refresh republishes the new anchors with the retained selection.
const refresh = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'near', name: 'Near' }],
});
h.completions.shift()();
await refresh;
assert.deepEqual(
h.overlay.published().map((entry) => [entry.id, entry.variant]),
[
['storm:near', 'selected'],
['lead:near:12', 'card'],
],
);
assert.equal(
h.overlay.entry('storm:near').position,
h.sources[0].entities.values[0].position,
);
// A superseded addition never publishes, and clear hides the source.
const stale = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
const afterClear = h.overlay.calls.slice(-2);
assert.deepEqual(afterClear, [
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
]);
h.completions.shift()();
assert.equal(await stale, false);
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
h.rendering.destroy();
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
});
+245
View File
@@ -0,0 +1,245 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > max ||
/[\u0000-\u001f<>]/.test(value)
)
throw malformed();
return value;
};
const number = (value, low, high) => {
if (value === null) return null;
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
return value;
};
const time = (value) => {
if (
typeof value !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw malformed();
return value;
};
const advisory = (value) => {
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
throw malformed();
return value;
};
const position = (value) => {
if (!value || value.longitude === null || value.latitude === null)
throw malformed();
return {
longitude: number(value.longitude, -180, 180),
latitude: number(value.latitude, -90, 90),
};
};
function officialLink(value, outlook = false) {
if (value === null) return null;
const url = new URL(text(value, 256));
if (
url.origin !== 'https://www.nhc.noaa.gov' ||
url.username ||
url.password ||
url.hash
)
throw malformed();
if (
outlook
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
url.pathname + url.search,
)
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
)
throw malformed();
return url.href;
}
function geometry(value, kind, budget) {
if (value === null) return null;
const allowed =
kind === 'track'
? ['LineString', 'MultiLineString']
: ['Polygon', 'MultiPolygon'];
if (!allowed.includes(value?.type)) throw malformed();
const point = (pair) => {
if (
!Array.isArray(pair) ||
pair.length !== 2 ||
pair.some((v) => typeof v !== 'number')
)
throw malformed();
position({ longitude: pair[0], latitude: pair[1] });
if (++budget.coordinates > 25_000) throw malformed();
return [...pair];
};
const list = (items, read, min = 1, max = 128) => {
if (!Array.isArray(items) || items.length < min || items.length > max)
throw malformed();
return items.map(read);
};
const line = (pairs) => list(pairs, point, 2, 10_000);
const ring = (pairs) => {
const result = list(pairs, point, 4, 10_000);
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
throw malformed();
return result;
};
const polygon = (rings) => list(rings, ring);
const coordinates =
value.type === 'LineString'
? line(value.coordinates)
: value.type === 'MultiLineString'
? list(value.coordinates, line)
: value.type === 'Polygon'
? polygon(value.coordinates)
: list(value.coordinates, polygon);
return { type: value.type, coordinates };
}
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
export function validateCycloneSnapshot(value) {
if (
!value ||
value.schemaVersion !== 1 ||
typeof value.stale !== 'boolean' ||
typeof value.unavailable !== 'boolean' ||
!Array.isArray(value.storms) ||
value.storms.length > 32
)
throw malformed();
const seen = new Set(),
budget = { coordinates: 0, points: 0 };
const storms = value.storms.map((raw) => {
if (
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
seen.has(raw.id) ||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
)
throw malformed();
seen.add(raw.id);
const advisoryNumber = advisory(raw.advisoryNumber);
const geometryAdvisoryNumber =
raw.geometryAdvisoryNumber === null
? null
: advisory(raw.geometryAdvisoryNumber);
if (
raw.geometryStatus === 'current' &&
geometryAdvisoryNumber !== advisoryNumber
)
throw malformed();
if (
!Array.isArray(raw.forecastPoints) ||
(budget.points += raw.forecastPoints.length) > 500
)
throw malformed();
const taus = new Set();
const forecastPoints = raw.forecastPoints.map((point) => {
const tauHours = number(point.tauHours, 0, 168);
if (tauHours === null || taus.has(tauHours)) throw malformed();
taus.add(tauHours);
if (++budget.coordinates > 25_000) throw malformed();
return {
position: position(point.position),
tauHours,
windKt: number(point.windKt, 0, 300),
gustKt: number(point.gustKt, 0, 350),
};
});
const track = geometry(raw.track, 'track', budget),
cone = geometry(raw.cone, 'cone', budget);
if (
raw.geometryStatus !== 'current' &&
(track || cone || forecastPoints.length)
)
throw malformed();
if (
raw.geometryStatus === 'current' &&
(!track || !cone || !forecastPoints.length)
)
throw malformed();
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
return {
id: raw.id,
name: text(raw.name, 80),
classification: text(raw.classification, 16),
basin: raw.basin,
position: position(raw.position),
positionAt: time(raw.positionAt),
advisoryNumber,
issuedAt: time(raw.issuedAt),
windKt: number(raw.windKt, 0, 300),
pressureHpa: number(raw.pressureHpa, 800, 1100),
movement: {
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
speedKt: number(raw.movement?.speedKt, 0, 200),
},
advisoryUrl: officialLink(raw.advisoryUrl),
outlookUrl: officialLink(raw.outlookUrl, true),
geometryStatus: raw.geometryStatus,
geometryAdvisoryNumber,
forecastPoints,
track,
cone,
};
});
if (value.unavailable && storms.length) throw malformed();
return {
schemaVersion: 1,
source: text(value.source),
attribution: text(value.attribution, 240),
coverage: text(value.coverage, 240),
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
stale: value.stale,
unavailable: value.unavailable,
reason: value.reason === null ? null : text(value.reason, 240),
storms,
};
}
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
export function createCycloneSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ signal } = {}) {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl('/api/cyclones', {
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Cyclone HTTP ${response.status}`);
}
const result = await readResponseJsonCapped(
response,
CYCLONE_RESPONSE_LIMIT,
controller.signal,
);
controller.signal.throwIfAborted();
return validateCycloneSnapshot(result);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCycloneSource,
validateCycloneSnapshot,
CYCLONE_RESPONSE_LIMIT,
} from './source.js';
const issuedAt = '2026-09-16T03:00:00.000Z';
function fixture() {
return {
schemaVersion: 1,
source: 'NOAA NHC / CPHC',
attribution: 'NOAA/NWS',
coverage: 'Atlantic and eastern/central North Pacific',
fetchedAt: Date.parse(issuedAt),
stale: false,
unavailable: false,
reason: null,
storms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: issuedAt,
issuedAt,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
movement: { directionDegrees: null, speedKt: null },
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
},
],
};
}
function current() {
const value = fixture(),
storm = value.storms[0];
storm.geometryStatus = 'current';
storm.geometryAdvisoryNumber = '10';
storm.forecastPoints = [
{
position: { longitude: 179, latitude: 15 },
tauHours: 12,
windKt: 30,
gustKt: 40,
},
];
storm.track = {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
[
[170, 14],
[179, 15],
],
],
};
storm.cone = {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
};
return value;
}
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
const value = current();
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
value.storms[0].cone.properties = {
html: '<script>',
href: 'https://example.invalid',
};
const result = validateCycloneSnapshot(value);
assert.deepEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
assert.equal(result.storms[0].cone.properties, undefined);
assert.notEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
});
test('successful empty coverage is distinct from unavailable', () => {
const value = fixture();
value.storms = [];
assert.equal(validateCycloneSnapshot(value).unavailable, false);
assert.equal(
validateCycloneSnapshot({
...value,
unavailable: true,
stale: true,
fetchedAt: null,
reason: 'Unavailable',
}).unavailable,
true,
);
assert.throws(
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
/Malformed/,
);
});
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
const changes = [
(storm) => {
storm.geometryAdvisoryNumber = '9';
},
(storm) => {
storm.geometryStatus = 'pending';
},
(storm) => {
storm.position.longitude = 181;
},
(storm) => {
storm.track.coordinates[0][0][0] = NaN;
},
(storm) => {
storm.cone.coordinates[0][0].pop();
},
(storm) => {
storm.forecastPoints.push(storm.forecastPoints[0]);
},
(storm) => {
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
},
(storm) => {
storm.issuedAt = '2026-02-31T00:00:00.000Z';
},
(storm) => {
storm.windKt = 9999;
},
];
for (const change of changes) {
const value = current();
change(value.storms[0]);
assert.throws(() => validateCycloneSnapshot(value));
}
const oversized = current();
oversized.storms[0].track = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () =>
Array.from({ length: 9000 }, () => [1, 2]),
),
};
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
assert.throws(
() =>
validateCycloneSnapshot({
...fixture(),
storms: Array(33).fill(fixture().storms[0]),
}),
/Malformed/,
);
});
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
const calls = [];
const source = createCycloneSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json(fixture());
},
});
assert.equal(calls.length, 0);
await source.getSnapshot();
assert.equal(calls[0].url, '/api/cyclones');
assert.equal(calls[0].options.redirect, 'error');
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls.length, 1);
const oversized = createCycloneSource({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
}),
});
await assert.rejects(oversized.getSnapshot(), /too large/);
});
test('source deadline cancels request and removes timer on completion', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const source = createCycloneSource({
timeoutMs: 10,
fetchImpl: async (_url, options) => {
signal = options.signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot();
const rejected = assert.rejects(pending, /timed out/);
t.mock.timers.tick(11);
await rejected;
assert.equal(signal.aborted, true);
});
+219
View File
@@ -0,0 +1,219 @@
/**
* NASA CMR granule catalog for the HLS products, folded with the VIIRS
* overview days into one candidate list.
*/
import {
CATALOG_DAYS,
PRODUCTS,
coverageFor,
groupGranulesByDay,
mergeCandidates,
utcDay,
validateBox,
viirsCandidates,
} from './model.js';
const CMR_ENDPOINT = 'https://cmr.earthdata.nasa.gov/search';
const DAY_MS = 86_400_000;
const CMR_PAGE_SIZE = 200;
/**
* Most records read per collection. A 1,000 km box can touch hundreds of HLS
* tiles a day; past this cap the newest days (the sort order) are kept and
* the result is `truncated`.
*/
const CMR_MAX_RECORDS = 2000;
const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
export async function fetchCmrPages({
product,
url,
fetchImpl,
signal,
maxRecords = CMR_MAX_RECORDS,
}) {
const granules = [];
let hits = 0;
let cursor = null;
for (;;) {
const init = { signal };
if (cursor) init.headers = { [CMR_SEARCH_AFTER]: cursor };
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
}
function finiteOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function isoOrNull(value) {
return typeof value === 'string' && Number.isFinite(Date.parse(value))
? value
: null;
}
function ringOf(points) {
const ring = points
.map(([lon, lat]) => [finiteOrNull(lon), finiteOrNull(lat)])
.filter(([lon, lat]) => lon !== null && lat !== null);
return ring.length >= 3 ? ring : null;
}
function footprintOf(geometry) {
const polygon = geometry?.GPolygons?.[0]?.Boundary?.Points;
if (Array.isArray(polygon)) {
const ring = ringOf(polygon.map((p) => [p?.Longitude, p?.Latitude]));
if (ring) return ring;
}
const r = geometry?.BoundingRectangles?.[0];
if (!r) return null;
const [w, s, e, n] = [
r.WestBoundingCoordinate,
r.SouthBoundingCoordinate,
r.EastBoundingCoordinate,
r.NorthBoundingCoordinate,
];
const ring = ringOf([
[w, s],
[e, s],
[e, n],
[w, n],
]);
return ring?.length === 4 ? ring : null;
}
/**
* Parse a CMR `granules.umm_json` response. Every field is optional upstream;
* granules without a parseable start time are dropped.
* @param {object} json
* @param {string} product `S30` | `L30`
* @returns {{ granules: Array<object>, hits: number }}
*/
export function parseCmrUmm(json, product) {
const granules = [];
for (const item of Array.isArray(json?.items) ? json.items : []) {
const umm = item?.umm || {};
const meta = item?.meta || {};
const range = umm.TemporalExtent?.RangeDateTime || {};
const timeStart = isoOrNull(range.BeginningDateTime);
if (!timeStart) continue;
const cloud = Array.isArray(umm.AdditionalAttributes)
? umm.AdditionalAttributes.find((a) => a?.Name === 'CLOUD_COVERAGE')
?.Values?.[0]
: null;
granules.push({
id: String(
meta['concept-id'] || umm.GranuleUR || meta['native-id'] || '',
),
product,
timeStart,
timeEnd: isoOrNull(range.EndingDateTime) || timeStart,
cloud: finiteOrNull(cloud),
footprint: footprintOf(
umm.SpatialExtent?.HorizontalSpatialDomain?.Geometry,
),
});
}
return { granules, hits: finiteOrNull(json?.hits) ?? granules.length };
}
/**
* CMR granule search URL for one HLS product over a box and time window.
* @param {{ product: string, box: object, startIso: string, endIso: string }} query
* @returns {string}
*/
export function cmrSearchUrl({ product, box, startIso, endIso }) {
const spec = PRODUCTS[product];
if (!spec?.cmrCollection)
throw new TypeError(`No CMR collection for product: ${product}`);
const result = validateBox(box);
if (!result.ok) throw new TypeError(result.message);
const { west, south, east, north } = result.box;
return (
`${CMR_ENDPOINT}/granules.umm_json` +
`?collection_concept_id=${spec.cmrCollection}` +
`&bounding_box=${west},${south},${east},${north}` +
`&temporal=${startIso},${endIso}` +
`&sort_key=-start_date&page_size=${CMR_PAGE_SIZE}`
);
}
/**
* Search both HLS collections concurrently and fold the results with the
* VIIRS overview days. One product failing still yields the other's days plus
* an `errors` entry; an invalid box or clock throws a TypeError.
* @param {{ box: object, days?: number, now?: Date, fetchImpl?: typeof fetch, signal?: AbortSignal }} request
* @returns {Promise<{ candidates: Array<object>, truncated: boolean, errors: Array<{ product: string, message: string }> }>}
*/
export async function searchHls({
box,
days = CATALOG_DAYS,
now = new Date(),
fetchImpl = globalThis.fetch,
signal,
} = {}) {
const validated = validateBox(box);
if (!validated.ok) throw new TypeError(validated.message);
if (typeof fetchImpl !== 'function')
throw new TypeError('A fetch implementation is required');
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
if (!Number.isFinite(nowMs)) throw new TypeError('A valid clock is required');
const endIso = new Date(nowMs).toISOString();
const startIso = `${utcDay(new Date(nowMs - (days - 1) * DAY_MS))}T00:00:00Z`;
const products = ['S30', 'L30'];
const settled = await Promise.allSettled(
products.map((product) =>
fetchCmrPages({
product,
url: cmrSearchUrl({ product, box: validated.box, startIso, endIso }),
fetchImpl,
signal,
}),
),
);
const granules = [];
const errors = [];
let truncated = false;
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
granules.push(...result.value.granules);
truncated ||= result.value.truncated;
} else {
errors.push({
product: products[index],
message: String(result.reason?.message || result.reason),
});
}
});
const hls = groupGranulesByDay(granules).map((candidate) => ({
...candidate,
coverage: coverageFor(candidate, validated.box),
}));
return {
candidates: mergeCandidates([hls, viirsCandidates(endIso, days)]),
truncated,
errors,
};
}
+296
View File
@@ -0,0 +1,296 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
cmrSearchUrl,
fetchCmrPages,
parseCmrUmm,
searchHls,
} from './catalog.js';
import { BOX, response } from './testDoubles.mjs';
function ummItem({
conceptId = 'G1-LPCLOUD',
begin = '2026-09-18T17:12:01.000Z',
end = '2026-09-18T17:12:31.000Z',
cloud = '12',
polygon = [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
rectangle = null,
} = {}) {
const Geometry = {};
if (polygon)
Geometry.GPolygons = [
{
Boundary: {
Points: polygon.map(([Longitude, Latitude]) => ({
Longitude,
Latitude,
})),
},
},
];
if (rectangle)
Geometry.BoundingRectangles = [
{
WestBoundingCoordinate: rectangle[0],
SouthBoundingCoordinate: rectangle[1],
EastBoundingCoordinate: rectangle[2],
NorthBoundingCoordinate: rectangle[3],
},
];
return {
meta: { 'concept-id': conceptId },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: begin, EndingDateTime: end },
},
AdditionalAttributes: [
{ Name: 'SPATIAL_COVERAGE', Values: ['100'] },
...(cloud === undefined
? []
: [{ Name: 'CLOUD_COVERAGE', Values: [cloud] }]),
],
SpatialExtent: { HorizontalSpatialDomain: { Geometry } },
},
};
}
test('umm_json granules carry id, times, scene cloud and a footprint ring', () => {
const { granules, hits } = parseCmrUmm(
{
hits: 9,
items: [
ummItem(),
ummItem({
conceptId: 'G2',
cloud: '3.5',
polygon: null,
rectangle: [-98.5, 29.5, -97.5, 30.5],
}),
{
meta: {},
umm: {
GranuleUR: 'HLS.L30.only-ur',
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-01T16:00:00Z' },
},
},
},
ummItem({ conceptId: 'G-bad-cloud', cloud: 'n/a' }),
ummItem({
conceptId: 'G-two-points',
polygon: [
[0, 0],
[1, 1],
],
}),
{ meta: { 'concept-id': 'G-no-time' }, umm: {} },
{
meta: { 'concept-id': 'G-bad-time' },
umm: {
TemporalExtent: { RangeDateTime: { BeginningDateTime: 'soon' } },
},
},
null,
],
},
'S30',
);
assert.equal(hits, 9);
assert.deepEqual(granules[0], {
id: 'G1-LPCLOUD',
product: 'S30',
timeStart: '2026-09-18T17:12:01.000Z',
timeEnd: '2026-09-18T17:12:31.000Z',
cloud: 12,
footprint: [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
});
assert.equal(granules[1].cloud, 3.5);
assert.deepEqual(granules[1].footprint, [
[-98.5, 29.5],
[-97.5, 29.5],
[-97.5, 30.5],
[-98.5, 30.5],
]);
assert.deepEqual(
granules.slice(2).map((g) => [g.id, g.cloud, g.footprint]),
[
['HLS.L30.only-ur', null, null],
['G-bad-cloud', null, granules[0].footprint],
['G-two-points', 12, null],
],
'missing fields stay unknown; granules without a start time are dropped',
);
assert.equal(granules[2].timeEnd, '2026-09-01T16:00:00Z');
assert.deepEqual(parseCmrUmm(null, 'S30'), { granules: [], hits: 0 });
});
test('the CMR query names the collection, box, window and page size with literal commas', () => {
assert.equal(
cmrSearchUrl({
product: 'S30',
box: BOX,
startIso: '2026-08-23T00:00:00Z',
endIso: '2026-09-21T12:00:00.000Z',
}),
'https://cmr.earthdata.nasa.gov/search/granules.umm_json?collection_concept_id=C2021957295-LPCLOUD&bounding_box=-97.8,30.2,-97.7,30.3&temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00.000Z&sort_key=-start_date&page_size=200',
);
assert.match(
cmrSearchUrl({ product: 'L30', box: BOX, startIso: 'a', endIso: 'b' }),
/collection_concept_id=C2021957657-LPCLOUD/,
);
assert.throws(
() => cmrSearchUrl({ product: 'VIIRS', box: BOX }),
/No CMR collection/,
);
});
/** Route a CMR request to the S30 or L30 handler by collection id. */
function cmrFetch(handlers, calls = []) {
return async (url, init) => {
calls.push({ url, init });
const handler = handlers[url.includes('C2021957295') ? 'S30' : 'L30'];
return typeof handler === 'function'
? handler()
: response({ json: handler });
};
}
test('both HLS collections fold with the VIIRS days; one failing product is reported, not fatal', async () => {
const calls = [];
const signal = new AbortController().signal;
const result = await searchHls({
box: BOX,
now: new Date('2026-09-21T12:00:00Z'),
signal,
fetchImpl: cmrFetch(
{
S30: {
hits: 2,
items: [
ummItem(),
ummItem({ conceptId: 'G-old', begin: '2026-09-08T17:12:01Z' }),
],
},
L30: () => response({ ok: false, status: 503 }),
},
calls,
),
});
assert.deepEqual(result.errors, [
{ product: 'L30', message: 'HTTP 503 from CMR' },
]);
assert.equal(result.truncated, false);
assert.equal(calls[0].init.signal, signal);
assert.match(
calls[0].url,
/temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00\.000Z/,
'30 UTC days ending now',
);
const keys = result.candidates.map((c) => c.key);
assert.equal(keys.length, 32);
assert.deepEqual(keys.slice(2, 5), [
'VIIRS:2026-09-19',
'S30:2026-09-18',
'VIIRS:2026-09-18',
]);
assert.ok(keys.includes('S30:2026-09-08'));
assert.equal(
result.candidates.find((c) => c.key === 'S30:2026-09-18').coverage,
'full',
'coverage is computed against the box',
);
const thrown = await searchHls({
box: BOX,
days: 2,
now: '2026-09-21T00:00:00Z',
fetchImpl: cmrFetch({
S30: () => {
throw new Error('network down');
},
L30: { hits: 500, items: [ummItem({ conceptId: 'G-L30' })] },
}),
});
assert.deepEqual(thrown.errors, [
{ product: 'S30', message: 'network down' },
]);
assert.equal(thrown.truncated, true, 'CMR has more hits than were read');
assert.deepEqual(
thrown.candidates.map((c) => c.key),
['VIIRS:2026-09-21', 'VIIRS:2026-09-20', 'L30:2026-09-18'],
);
});
test('an invalid box or clock is refused before any request', async () => {
let called = false;
const fetchImpl = async () => {
called = true;
};
await assert.rejects(
searchHls({
box: { west: 179, south: 0, east: -179, north: 1 },
fetchImpl,
}),
/dateline/,
);
await assert.rejects(
searchHls({ box: BOX, now: 'never', fetchImpl }),
/clock/,
);
assert.equal(called, false);
});
test('CMR pages follow the CMR-Search-After cursor up to 2,000 records and truncation stays honest', async () => {
const items = (count) =>
Array.from({ length: count }, (_, i) => ummItem({ conceptId: `G-${i}` }));
const cursors = [];
const paged = await fetchCmrPages({
product: 'S30',
url: 'https://cmr/x',
fetchImpl: async (url, init) => {
const page = cursors.push(init.headers?.['CMR-Search-After'] ?? null);
return response({
headers: { 'CMR-Search-After': `cursor-${page}` },
json: { hits: 450, items: items(page < 3 ? 200 : 50) },
});
},
});
assert.equal(paged.granules.length, 450);
assert.equal(paged.truncated, false);
assert.deepEqual(cursors, [null, 'cursor-1', 'cursor-2']);
let pages = 0;
const capped = await fetchCmrPages({
product: 'L30',
url: 'https://cmr/y',
fetchImpl: async () => {
pages += 1;
return response({
headers: { 'cmr-search-after': 'more' },
json: { hits: 5000, items: items(200) },
});
},
});
assert.equal(pages, 10);
assert.equal(capped.granules.length, 2000);
assert.equal(capped.truncated, true);
const noCursor = await fetchCmrPages({
product: 'S30',
url: 'u',
fetchImpl: async () => response({ json: { hits: 900, items: items(200) } }),
});
assert.equal(noCursor.granules.length, 200);
assert.equal(noCursor.truncated, true);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+613
View File
@@ -0,0 +1,613 @@
/**
* Pure domain model for the Recent Imagery layer: products, the selection box,
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
* no network.
*/
/** How far back the catalog looks, in UTC days. */
export const CATALOG_DAYS = 30;
const MAX_BOX_SIDE_KM = 1000;
/** ZOOM IN lands where the top-down view is this wide, well inside the cap. */
const FIT_VIEW_KM = 400;
const FIT_HEIGHT_KM = Object.freeze({ min: 5, max: 400 });
/** GIBS EPSG:3857 tiles do not exist beyond the Web-Mercator limit. */
const MERCATOR_LAT_LIMIT = 85.0511;
const MAX_CLOUD_FOR_CLEAR = 20;
const PIN_BOX_SIDE_KM = 10;
const WVS_ENDPOINT = 'https://wvs.earthdata.nasa.gov/api/v1/snapshot';
const KM_PER_DEG_LAT = 111.32;
const QUANTUM = 100000;
const DAY_MS = 86_400_000;
const PRODUCT_ORDER = ['S30', 'L30', 'VIIRS'];
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
/**
* Keyless, browser-direct imagery products. HLS products are searched via
* CMR; the VIIRS overview is a daily global mosaic with no granule catalog.
* `source` is the on/off group the row chips toggle.
*/
export const PRODUCTS = Object.freeze({
S30: Object.freeze({
label: 'HLS S30',
name: 'Sentinel-2',
source: 'hls',
sensor: 'Sentinel-2 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957295-LPCLOUD',
overview: false,
}),
L30: Object.freeze({
label: 'HLS L30',
name: 'Landsat 8/9',
source: 'hls',
sensor: 'Landsat 8/9 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957657-LPCLOUD',
overview: false,
}),
VIIRS: Object.freeze({
label: 'VIIRS',
name: 'Daily overview',
source: 'viirs',
sensor: 'VIIRS NOAA-21',
resolutionM: 250,
gibsLayer: 'VIIRS_NOAA21_CorrectedReflectance_TrueColor',
maxLevel: 9,
format: 'jpg',
cmrCollection: null,
overview: true,
}),
});
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const toRadians = (degrees) => (degrees * Math.PI) / 180;
const pad2 = (value) => String(value).padStart(2, '0');
/**
* Coerce a box to `{ west, south, east, north }` with south ≤ north. West and
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
box.west,
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
return {
west,
south: Math.min(south, north),
east,
north: Math.max(south, north),
};
}
/**
* Side lengths of a box in km: the box's own longitude span (never the
* shorter way round the globe) at its mid-latitude, and its latitude span.
* @param {object} box Degrees box.
* @returns {{ width: number, height: number }}
*/
export function boxSideKm(box) {
const normalized = normalizeBox(box);
if (!normalized) return { width: NaN, height: NaN };
const midLat = (normalized.south + normalized.north) / 2;
return {
width:
(normalized.east - normalized.west) *
KM_PER_DEG_LAT *
Math.cos(toRadians(midLat)),
height: (normalized.north - normalized.south) * KM_PER_DEG_LAT,
};
}
const refuse = (reason, message) => ({ ok: false, reason, message });
/**
* Validate a selection box for the catalog and the tile providers.
* @param {object} box Degrees box.
* @returns {{ ok: true, box: object } | { ok: false, reason: 'dateline' | 'polar' | 'degenerate' | 'too-large' | 'invalid', message: string }}
*/
export function validateBox(box) {
const normalized = normalizeBox(box);
if (!normalized) return refuse('invalid', 'Box edges must be finite');
const { west, south, east, north } = normalized;
if (Math.abs(west) > 180 || Math.abs(east) > 180)
return refuse('invalid', 'Longitudes must be within ±180°');
if (west > east) return refuse('dateline', 'Select one side of the dateline');
if (
Math.abs(south) > MERCATOR_LAT_LIMIT ||
Math.abs(north) > MERCATOR_LAT_LIMIT
)
return refuse(
'polar',
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
);
if (east === west || north === south)
return refuse('degenerate', 'Box must enclose an area');
const { width, height } = boxSideKm(normalized);
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
return { ok: true, box: normalized };
}
const formatKm = (km) => Math.round(Number(km) || 0).toLocaleString('en-US');
// One short line each: the panel's hint says what to do about it.
function tooLargeMessage(sideKm) {
return `Box is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/** The refusal for USE VIEW when the camera sees more than the cap allows. */
export function viewTooLargeMessage(sideKm) {
return `View is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/**
* The camera height, in metres, at which a top-down view is `FIT_VIEW_KM`
* wide: the horizontal half-angle is atan(tan(fovy / 2) · width / height).
* Clamped to 5–400 km; without a canvas or FOV, a square 60° view.
* @param {{ width?: number, height?: number, fovy?: number }} view Canvas size in px, vertical FOV in radians.
* @returns {number}
*/
export function fitViewHeightM({ width, height, fovy } = {}) {
const aspect = Number(width) / Number(height);
const valid =
Number.isFinite(aspect) && aspect > 0 && fovy > 0 && fovy < Math.PI;
const halfWidth = valid ? Math.tan(fovy / 2) * aspect : Math.tan(Math.PI / 6);
const km = FIT_VIEW_KM / (2 * halfWidth);
return Math.min(FIT_HEIGHT_KM.max, Math.max(FIT_HEIGHT_KM.min, km)) * 1000;
}
/**
* The centre of a box that does not cross the dateline, in degrees.
* @param {object} box Degrees box.
* @returns {{ lon: number, lat: number } | null}
*/
export function boxCentre(box) {
const normalized = normalizeBox(box);
if (!normalized) return null;
return {
lon: (normalized.west + normalized.east) / 2,
lat: (normalized.south + normalized.north) / 2,
};
}
/**
* A square box centred on a pin, `sideKm` on the ground (the east–west extent
* widens by 1/cos(lat)); null when the result would not validate.
* @param {number} lon Degrees.
* @param {number} lat Degrees.
* @param {number} [sideKm]
* @returns {object | null}
*/
export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
if (!finite(lon) || !finite(lat) || !finite(sideKm) || sideKm <= 0)
return null;
const cosLat = Math.cos(toRadians(lat));
if (cosLat <= 0) return null;
const halfLat = sideKm / 2 / KM_PER_DEG_LAT;
const halfLon = sideKm / 2 / (KM_PER_DEG_LAT * cosLat);
const clampLat = (value) =>
Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, value));
const result = validateBox({
west: lon - halfLon,
south: clampLat(lat - halfLat),
east: lon + halfLon,
north: clampLat(lat + halfLat),
});
return result.ok ? result.box : null;
}
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
});
}
/** Degrees box from a radians rectangle (Cesium `Rectangle` shape). */
export function boxFromRectangle(rectangle) {
return mapBox(rectangle, (radians) => (Number(radians) * 180) / Math.PI);
}
/** Degrees box → signed integers at degrees × 100000 (the share-link form). */
export function quantizeBox(box) {
return mapBox(normalizeBox(box), (value) => Math.round(value * QUANTUM));
}
/** Inverse of `quantizeBox`. */
export function dequantizeBox(ints) {
return mapBox(ints, (value) => Number(value) / QUANTUM);
}
function isValidDay(day) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
if (!match) return false;
const time = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
);
return (
Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day
);
}
/**
* UTC calendar day (`YYYY-MM-DD`) of an ISO timestamp, Date or day string;
* null when unparseable or not a real day.
* @param {string | number | Date} value
* @returns {string | null}
*/
export function utcDay(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
return isValidDay(value) ? value : null;
const time = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: null;
}
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
return match ? `${MONTHS[Number(match[1]) - 1]} ${Number(match[2])}` : '';
}
/** `17:12` (UTC) for an ISO timestamp; '' when unparseable. */
export function hhmm(iso) {
const time = Date.parse(iso);
if (!Number.isFinite(time)) return '';
const date = new Date(time);
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
}
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
export function candidateKey(product, day) {
return `${product}:${day}`;
}
/** Split a candidate key; null when the product is unknown or the day invalid. */
export function parseCandidateKey(key) {
const match = /^(S30|L30|VIIRS):(\d{4}-\d{2}-\d{2})$/.exec(String(key));
if (!match || !isValidDay(match[2])) return null;
return { product: match[1], day: match[2] };
}
const cloudOf = (granule) => (finite(granule?.cloud) ? granule.cloud : null);
function buildCandidate(product, day, granules) {
const clouds = granules.map(cloudOf).filter((value) => value !== null);
const starts = granules
.map((granule) => Date.parse(granule.timeStart))
.filter(Number.isFinite);
const ends = granules
.map((granule) => Date.parse(granule.timeEnd ?? granule.timeStart))
.filter(Number.isFinite);
return {
key: candidateKey(product, day),
product,
day,
granules,
cloud: clouds.length
? { min: Math.min(...clouds), max: Math.max(...clouds) }
: null,
timeRange:
starts.length && ends.length
? {
start: new Date(Math.min(...starts)).toISOString(),
end: new Date(Math.max(...ends)).toISOString(),
}
: null,
availability: granules.length ? 'present' : 'empty',
coverage: 'unknown',
};
}
/**
* Group granules into one candidate per product and UTC day of `timeStart`,
* newest first. Granules without a known product or start time are dropped.
* @param {Array<object>} granules
* @returns {Array<object>}
*/
export function groupGranulesByDay(granules) {
const buckets = new Map();
for (const granule of Array.isArray(granules) ? granules : []) {
const day = utcDay(granule?.timeStart);
if (!PRODUCTS[granule?.product] || !day) continue;
const key = candidateKey(granule.product, day);
if (!buckets.has(key))
buckets.set(key, { product: granule.product, day, granules: [] });
buckets.get(key).granules.push(granule);
}
return mergeCandidates([
[...buckets.values()].map(({ product, day, granules: list }) =>
buildCandidate(product, day, list),
),
]);
}
/**
* One VIIRS overview candidate per UTC day for the last `days` days, today
* first. Availability stays unknown until the thumbnail probe answers;
* coverage is full because the overview is a global mosaic.
* @param {string | Date} todayIso
* @param {number} [days]
* @returns {Array<object>}
*/
export function viirsCandidates(todayIso, days = CATALOG_DAYS) {
const today = utcDay(todayIso);
if (!today) return [];
const start = Date.parse(`${today}T00:00:00Z`);
const out = [];
for (let i = 0; i < Math.max(0, Math.trunc(days)); i += 1) {
const day = new Date(start - i * DAY_MS).toISOString().slice(0, 10);
out.push({
key: candidateKey('VIIRS', day),
product: 'VIIRS',
day,
granules: [],
cloud: null,
timeRange: null,
availability: 'unknown',
coverage: 'full',
});
}
return out;
}
/**
* Merge candidate lists newest day first, then S30, L30, VIIRS; the first
* occurrence of a key wins.
* @param {Array<Array<object>>} lists
* @returns {Array<object>}
*/
export function mergeCandidates(lists) {
const byKey = new Map();
for (const list of lists) {
for (const candidate of list || []) {
if (candidate?.key && !byKey.has(candidate.key))
byKey.set(candidate.key, candidate);
}
}
return [...byKey.values()].sort((a, b) =>
a.day !== b.day
? a.day < b.day
? 1
: -1
: PRODUCT_ORDER.indexOf(a.product) - PRODUCT_ORDER.indexOf(b.product),
);
}
function pointInPolygon(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const crosses =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi;
if (crosses) inside = !inside;
}
return inside;
}
/**
* How much of the box a candidate's granule footprints cover, sampled at the
* four corners and the centre: `full`, `partial`, or `unknown` without
* footprints.
* @param {object} candidate
* @param {object} box Degrees box.
* @returns {'full' | 'partial' | 'unknown'}
*/
export function coverageFor(candidate, box) {
const normalized = normalizeBox(box);
const footprints = (candidate?.granules || [])
.map((granule) => granule?.footprint)
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
if (!normalized || !footprints.length) return 'unknown';
const { west, south, east, north } = normalized;
const samples = [
[west, south],
[east, south],
[east, north],
[west, north],
[(west + east) / 2, (south + north) / 2],
];
return samples.every(([lon, lat]) =>
footprints.some((ring) => pointInPolygon(lon, lat, ring)),
)
? 'full'
: 'partial';
}
/**
* Pick the START HERE day. Coverage first: a present HLS day that covers the
* whole box (`full`, or `unknown` without footprints) beats a newer sliver at
* the box edge, which changes none of the frame when draped. Within a tier
* the newest day whose granules ALL have known cloud ≤ `maxCloud` wins
* (`clear`), else the newest (`cloudy`); only slivers left → `partial`; else
* the newest non-empty VIIRS day (`overview`). `certain` is false when the
* catalog was truncated.
* @param {Array<object>} candidates
* @param {{ maxCloud?: number, truncated?: boolean }} [options]
* @returns {{ candidate: object | null, reason: 'clear' | 'cloudy' | 'partial' | 'overview' | null, certain: boolean }}
*/
export function rankLatest(
candidates,
{ maxCloud = MAX_CLOUD_FOR_CLEAR, truncated = false } = {},
) {
const sorted = mergeCandidates([candidates]);
const certain = !truncated;
const present = (c) => c.availability !== 'empty';
const hlsDay = (c) =>
PRODUCTS[c.product] &&
!PRODUCTS[c.product].overview &&
present(c) &&
c.granules.length > 0;
const coversBox = (c) => c.coverage !== 'partial';
const clearSky = (c) =>
c.granules.every((granule) => {
const cloud = cloudOf(granule);
return cloud !== null && cloud <= maxCloud;
});
const tiers = [
[(c) => hlsDay(c) && coversBox(c) && clearSky(c), 'clear'],
[(c) => hlsDay(c) && coversBox(c), 'cloudy'],
[(c) => hlsDay(c) && clearSky(c), 'partial'],
[hlsDay, 'partial'],
[(c) => c.product === 'VIIRS' && present(c), 'overview'],
];
for (const [matches, reason] of tiers) {
const candidate = sorted.find(matches);
if (candidate) return { candidate, reason, certain };
}
return { candidate: null, reason: null, certain };
}
function daysAgoLabel(day, now) {
const today = utcDay(now);
if (!today) return null;
const delta = Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
if (delta <= 0) return 'today';
return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
if (!cloud) return 'cloud unknown';
const min = Math.round(cloud.min);
const max = Math.round(cloud.max);
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
* @returns {string}
*/
export function formatCandidateReadout(candidate, now = new Date()) {
const product = PRODUCTS[candidate?.product];
if (!product || !candidate?.day) return '';
const [y, m, d] = candidate.day.split('-').map(Number);
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
when +=
(candidate.granules?.length || 0) > 1 && end !== start
? ` ${start}–${end}Z`
: ` ${start}Z`;
}
const parts = [when];
const ago = daysAgoLabel(candidate.day, now);
if (ago) parts.push(ago);
parts.push(product.sensor, `${product.resolutionM} m`);
if (product.overview) parts.push('overview');
parts.push(cloudLabel(candidate.cloud));
return parts.join(' · ');
}
/**
* GIBS WMTS REST template with `{s}`, `{z}`, `{y}`, `{x}` placeholders (y
* precedes x in the GIBS path).
* @param {string} product
* @param {string} day `YYYY-MM-DD`
* @returns {string}
*/
export function gibsTemplate(product, day) {
const spec = PRODUCTS[product];
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
return `https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/${spec.gibsLayer}/default/${day}/GoogleMapsCompatible_Level${spec.maxLevel}/{z}/{y}/{x}.${spec.format}`;
}
/**
* Worldview Snapshots URL for a thumbnail or export. EPSG:4326 BBOX order is
* lat,lon: `south,west,north,east`.
* @param {{ product: string, day: string, box: object, width: number, height: number, format?: string }} request
* @returns {string}
*/
export function wvsSnapshotUrl({
product,
day,
box,
width,
height,
format = 'image/png',
}) {
const spec = PRODUCTS[product];
const normalized = normalizeBox(box);
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
if (!normalized) throw new TypeError('A finite box is required');
const bbox = [
normalized.south,
normalized.west,
normalized.north,
normalized.east,
]
.map((value) => String(Number(value.toFixed(6))))
.join(',');
return (
`${WVS_ENDPOINT}?REQUEST=GetSnapshot&LAYERS=${spec.gibsLayer}` +
`&CRS=EPSG:4326&TIME=${day}&BBOX=${bbox}` +
`&WIDTH=${Math.round(width)}&HEIGHT=${Math.round(height)}&FORMAT=${format}`
);
}
/**
* Thumbnail load order: the focused card (clamped into the visible range),
* the visible cards outward from it, then `extra` cards beyond each edge.
* @param {number} focusIndex
* @param {number} firstVisible
* @param {number} lastVisible
* @param {number} total
* @param {number} [extra]
* @returns {number[]}
*/
export function thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
total,
extra = 2,
) {
const count = Math.max(0, Math.trunc(total));
if (!count) return [];
const clamp = (value) => Math.max(0, Math.min(count - 1, Math.trunc(value)));
const first = clamp(Math.min(firstVisible, lastVisible));
const last = clamp(Math.max(firstVisible, lastVisible));
const focus = Math.max(first, Math.min(last, clamp(focusIndex)));
const order = [];
const push = (index) => {
if (index >= 0 && index < count && !order.includes(index))
order.push(index);
};
push(focus);
for (let step = 1; step <= last - first; step += 1) {
if (focus + step <= last) push(focus + step);
if (focus - step >= first) push(focus - step);
}
for (let step = 1; step <= Math.max(0, Math.trunc(extra)); step += 1) {
push(last + step);
push(first - step);
}
return order;
}
+439
View File
@@ -0,0 +1,439 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxFromPin,
boxFromRectangle,
boxSideKm,
coverageFor,
boxCentre,
dequantizeBox,
fitViewHeightM,
formatCandidateReadout,
gibsTemplate,
groupGranulesByDay,
mergeCandidates,
parseCandidateKey,
quantizeBox,
rankLatest,
shortDay,
thumbnailOrder,
utcDay,
validateBox,
viewTooLargeMessage,
viirsCandidates,
wvsSnapshotUrl,
} from './model.js';
import { BOX } from './testDoubles.mjs';
function granule(overrides = {}) {
return {
id: 'g',
product: 'S30',
timeStart: '2026-09-18T17:12:00.000Z',
timeEnd: '2026-09-18T17:12:30.000Z',
cloud: 12,
footprint: null,
...overrides,
};
}
function hls(product, day, clouds, extra = {}) {
const granules = clouds.map((cloud, index) =>
granule({
id: `${product}-${day}-${index}`,
product,
timeStart: `${day}T17:1${index}:00.000Z`,
timeEnd: `${day}T17:1${index}:30.000Z`,
cloud,
}),
);
return { ...groupGranulesByDay(granules)[0], ...extra };
}
test('validateBox refuses dateline, polar, degenerate, invalid and oversized boxes', () => {
const reason = (box) => validateBox(box).reason;
assert.equal(
reason({ west: 179, south: 0, east: -179, north: 1 }),
'dateline',
);
assert.equal(
reason({ west: 0, south: 84, east: 0.5, north: 85.05111 }),
'polar',
);
assert.equal(
reason({ west: 0, south: -85.0512, east: 0.5, north: -84 }),
'polar',
);
// Refusals fit the one-line notice.
assert.equal(
validateBox({ west: 0, south: 84, east: 0.5, north: 85.05111 }).message,
'Imagery stops at ±85.05° latitude',
);
assert.equal(
validateBox({ west: 179, south: 0, east: -179, north: 1 }).message,
'Select one side of the dateline',
);
assert.equal(
validateBox({ west: 0, south: 84.9, east: 0.05, north: 85 }).ok,
true,
);
assert.equal(
reason({ west: 10, south: 10, east: 10, north: 11 }),
'degenerate',
);
assert.equal(
reason({ west: Infinity, south: 0, east: 1, north: 1 }),
'invalid',
);
assert.equal(reason({ west: -181, south: 0, east: 1, north: 1 }), 'invalid');
assert.equal(reason(undefined), 'invalid');
// The cap is 1,000 km a side, and the refusal says how big the box is.
const tall = validateBox({ west: 0, south: 0, east: 0.1, north: 10 });
assert.equal(tall.reason, 'too-large');
assert.equal(tall.message, 'Box is 1,113 km wide · limit 1,000 km');
assert.equal(
reason({ west: 0, south: 0, east: 10, north: 0.1 }),
'too-large',
);
assert.equal(validateBox({ west: 0, south: 0, east: 8, north: 8 }).ok, true);
assert.equal(
viewTooLargeMessage(2400),
'View is 2,400 km wide · limit 1,000 km',
);
// Latitudes are ordered; longitudes are never swapped.
assert.deepEqual(
validateBox({ west: -97.8, south: 30.3, east: -97.7, north: 30.2 }).box,
BOX,
);
});
test('ZOOM IN: the height whose top-down view is 400 km wide, clamped to 5–400 km; a box centre', () => {
const near = (actual, expected, label) =>
assert.ok(Math.abs(actual - expected) < 1e-6, `${label}: ${actual}`);
// A 1000 × 500 canvas with Cesium's 60° horizontal FOV: tan(fovy / 2) is
// tan(30°) / 2, so the view is 2 · h · tan(30°) wide.
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2);
const height = fitViewHeightM({ width: 1000, height: 500, fovy });
near(height, 400_000 / (2 * Math.tan(Math.PI / 6)), 'wide canvas');
near(
2 * height * Math.tan(fovy / 2) * 2,
400_000,
'the view at that height is 400 km wide',
);
// A tall canvas would need more than 400 km; a very wide one less than 5.
assert.equal(
fitViewHeightM({ width: 200, height: 1000, fovy: Math.PI / 3 }),
400_000,
);
assert.equal(fitViewHeightM({ width: 1000, height: 500, fovy: 3.1 }), 5_000);
// No canvas or FOV: a square 60° view.
near(
fitViewHeightM({ width: 0, height: 0, fovy: undefined }),
400_000 / (2 * Math.tan(Math.PI / 6)),
'fallback',
);
assert.deepEqual(boxCentre({ west: 0, south: 0, east: 20, north: 10 }), {
lon: 10,
lat: 5,
});
assert.equal(boxCentre(null), null);
});
test('box width is the longitude span at the mid-latitude, never the shortest arc', () => {
const equator = boxSideKm({ west: 0, south: -0.5, east: 1, north: 0.5 });
assert.ok(Math.abs(equator.width - 111.32) < 1e-6);
assert.ok(Math.abs(equator.height - 111.32) < 1e-6);
const north = boxSideKm({ west: 0, south: 59.5, east: 1, north: 60.5 });
assert.ok(north.width > 55 && north.width < 56.5, String(north.width));
const nearlyGlobal = { west: -179.9, south: 30, east: 179.9, north: 30.1 };
assert.ok(boxSideKm(nearlyGlobal).width > 30000);
assert.equal(validateBox(nearlyGlobal).reason, 'too-large');
});
test('boxFromPin builds a 10 km square within 2% at any latitude and refuses what cannot validate', () => {
for (const [lon, lat] of [
[-97.75, 30.27],
[18.07, 59.33],
[0, 0],
[-70, -45],
]) {
const box = boxFromPin(lon, lat);
const { width, height } = boxSideKm(box);
assert.ok(Math.abs(width - 10) < 0.2, `width ${width} at ${lat}`);
assert.ok(Math.abs(height - 10) < 0.2, `height ${height} at ${lat}`);
assert.ok(Math.abs((box.west + box.east) / 2 - lon) < 1e-9);
}
assert.ok(Math.abs(boxSideKm(boxFromPin(10, 10, 50)).width - 50) < 1);
assert.equal(boxFromPin(NaN, 0), null);
assert.equal(boxFromPin(0, 0, 5000), null, 'over the cap');
assert.equal(boxFromPin(179.99, 0), null, 'would cross the dateline');
assert.equal(boxFromPin(0, 89), null, 'clamped to a degenerate polar box');
assert.equal(boxFromPin(0, 85.04).north, 85.0511, 'clamped, still valid');
});
test('rectangles convert from radians and the share-link quantization round-trips', () => {
const box = boxFromRectangle({
west: -Math.PI / 2,
south: 0,
east: -Math.PI / 4,
north: Math.PI / 6,
});
assert.ok(Math.abs(box.west + 90) < 1e-9 && Math.abs(box.north - 30) < 1e-9);
assert.equal(boxFromRectangle(null), null);
const ints = quantizeBox({
west: -97.812345678,
south: 30.2,
east: -97.7,
north: 30.3999999,
});
assert.deepEqual(ints, {
west: -9781235,
south: 3020000,
east: -9770000,
north: 3040000,
});
assert.deepEqual(quantizeBox(dequantizeBox(ints)), ints);
assert.equal(quantizeBox(null), null);
assert.equal(dequantizeBox({ west: 'x' }), null);
});
test('days and candidate keys are real UTC calendar days', () => {
assert.equal(utcDay('2026-09-18T20:00:00-05:00'), '2026-09-19');
assert.equal(utcDay(new Date(Date.UTC(2026, 0, 1))), '2026-01-01');
assert.equal(utcDay('2026-02-30'), null);
assert.equal(utcDay('nope'), null);
assert.deepEqual(parseCandidateKey('L30:2026-09-10'), {
product: 'L30',
day: '2026-09-10',
});
assert.equal(parseCandidateKey('X30:2026-09-10'), null);
assert.equal(parseCandidateKey('S30:2026-02-30'), null);
assert.equal(shortDay('2026-09-18'), 'Sep 18');
assert.equal(shortDay('nope'), '');
});
test('granules group into one candidate per product and UTC day, newest first', () => {
const candidates = groupGranulesByDay([
granule({ id: 'a', cloud: 12, timeEnd: '2026-09-18T17:12:40Z' }),
granule({
id: 'b',
cloud: 18,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:40Z',
}),
granule({
id: 'c',
cloud: null,
product: 'L30',
timeStart: '2026-09-18T16:00:00Z',
timeEnd: null,
}),
granule({ id: 'd', cloud: 3, timeStart: '2026-09-10T17:00:00Z' }),
granule({ id: 'e', timeStart: 'garbage' }),
granule({ id: 'f', product: 'nope' }),
]);
assert.deepEqual(
candidates.map((c) => c.key),
['S30:2026-09-18', 'L30:2026-09-18', 'S30:2026-09-10'],
);
const [s18, l18] = candidates;
assert.deepEqual(s18.cloud, { min: 12, max: 18 });
assert.deepEqual(s18.timeRange, {
start: '2026-09-18T17:12:00.000Z',
end: '2026-09-18T17:20:40.000Z',
});
assert.equal(s18.availability, 'present');
assert.equal(l18.cloud, null, 'missing cloud stays unknown');
assert.equal(l18.timeRange.end, '2026-09-18T16:00:00.000Z');
});
test('VIIRS lists the last 30 UTC days as unprobed full-coverage days', () => {
const days = viirsCandidates('2026-03-01T13:00:00Z', 3);
assert.deepEqual(
days.map((c) => c.key),
['VIIRS:2026-03-01', 'VIIRS:2026-02-28', 'VIIRS:2026-02-27'],
);
assert.equal(days[0].availability, 'unknown');
assert.equal(days[0].coverage, 'full');
assert.equal(viirsCandidates('2026-09-21').length, 30);
assert.deepEqual(viirsCandidates('bad'), []);
});
test('merging sorts newest day first, then S30, L30, VIIRS, and the first key wins', () => {
const merged = mergeCandidates([
viirsCandidates('2026-09-18', 2),
[hls('L30', '2026-09-18', [5]), hls('S30', '2026-09-17', [5])],
[hls('S30', '2026-09-18', [7]), hls('S30', '2026-09-18', [99])],
]);
assert.deepEqual(
merged.map((c) => c.key),
[
'S30:2026-09-18',
'L30:2026-09-18',
'VIIRS:2026-09-18',
'S30:2026-09-17',
'VIIRS:2026-09-17',
],
);
assert.deepEqual(merged[0].cloud, { min: 7, max: 7 });
});
test('coverage samples the box corners and centre against the union of footprints', () => {
const ring = (west, east) => [
[west, 30],
[east, 30],
[east, 31],
[west, 31],
];
const withRings = (...rings) => ({
granules: rings.map((footprint) => granule({ footprint })),
});
assert.equal(coverageFor(withRings(ring(-98, -97)), BOX), 'full');
assert.equal(coverageFor(withRings(ring(-98, -97.75)), BOX), 'partial');
assert.equal(
coverageFor(withRings(ring(-98, -97.75), ring(-97.75, -97)), BOX),
'full',
);
assert.equal(coverageFor(withRings(null), BOX), 'unknown');
});
test('START HERE is the newest all-clear HLS day; unknown cloud is never clear', () => {
const candidates = [
hls('S30', '2026-09-20', [10, null]),
hls('S30', '2026-09-19', [5, 21]),
hls('S30', '2026-09-18', [0, 20]),
];
assert.deepEqual(rankLatest([...candidates].reverse()), {
candidate: candidates[2],
reason: 'clear',
certain: true,
});
assert.equal(rankLatest(candidates, { truncated: true }).certain, false);
// Then the newest cloudy HLS day, then the newest non-empty VIIRS day.
const cloudy = rankLatest([
hls('L30', '2026-09-20', [45]),
hls('S30', '2026-09-19', [null]),
]);
assert.equal(cloudy.candidate.key, 'L30:2026-09-20');
assert.equal(cloudy.reason, 'cloudy');
const viirs = viirsCandidates('2026-09-21', 3);
viirs[0] = { ...viirs[0], availability: 'empty' };
const empty = { ...hls('S30', '2026-09-21', [0]), availability: 'empty' };
const overview = rankLatest([empty, ...viirs]);
assert.equal(overview.candidate.key, 'VIIRS:2026-09-20');
assert.equal(overview.reason, 'overview');
assert.equal(rankLatest([]).candidate, null);
});
test('START HERE prefers a day that covers the whole box over a newer sliver', () => {
const full = (day, clouds) => hls('S30', day, clouds, { coverage: 'full' });
const sliver = (day, clouds) =>
hls('S30', day, clouds, { coverage: 'partial' });
const pick = (list) => {
const { candidate, reason } = rankLatest(list);
return `${candidate?.day} ${reason}`;
};
assert.equal(
pick([sliver('2026-09-20', [0]), full('2026-09-18', [10])]),
'2026-09-18 clear',
);
assert.equal(
pick([full('2026-09-20', [null]), sliver('2026-09-19', [0])]),
'2026-09-20 cloudy',
);
// Unknown coverage (no footprints) counts as whole-box.
assert.equal(
pick([
hls('S30', '2026-09-20', [5], { coverage: 'unknown' }),
full('2026-09-18', [5]),
]),
'2026-09-20 clear',
);
// Only slivers: the newest clear one, then the newest at all.
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [0])]),
'2026-09-19 partial',
);
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [null])]),
'2026-09-20 partial',
);
assert.equal(
pick([...viirsCandidates('2026-09-21', 2), sliver('2026-09-19', [70])]),
'2026-09-19 partial',
'a sliver still beats the overview',
);
});
test('the readout is one UTC line with age, sensor, resolution and scene cloud', () => {
const now = '2026-09-21T12:00:00Z';
const readout = (granules) =>
formatCandidateReadout(groupGranulesByDay(granules)[0], now);
assert.equal(
readout([granule({ timeStart: '2026-09-18T17:12:10Z' })]),
'Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud',
);
assert.equal(
readout([
granule({ id: 'a' }),
granule({
id: 'b',
cloud: 18.4,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:30Z',
}),
]),
'Sep 18, 2026 17:12–17:20Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12–18% scene cloud',
);
assert.equal(
readout([
granule({
product: 'L30',
cloud: null,
timeStart: '2026-09-20T16:05:00Z',
timeEnd: null,
}),
]),
'Sep 20, 2026 16:05Z · yesterday · Landsat 8/9 via HLS · 30 m · cloud unknown',
);
assert.equal(
formatCandidateReadout(viirsCandidates('2026-09-21', 1)[0], now),
'Sep 21, 2026 · today · VIIRS NOAA-21 · 250 m · overview · cloud unknown',
);
assert.equal(formatCandidateReadout(null), '');
});
test('GIBS templates put y before x; Worldview snapshots take south,west,north,east', () => {
assert.equal(
gibsTemplate('S30', '2026-09-18'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/HLS_S30_Nadir_BRDF_Adjusted_Reflectance/default/2026-09-18/GoogleMapsCompatible_Level12/{z}/{y}/{x}.png',
);
assert.equal(
gibsTemplate('VIIRS', '2026-09-21'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA21_CorrectedReflectance_TrueColor/default/2026-09-21/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg',
);
assert.match(gibsTemplate('L30', '2026-09-10'), /HLS_L30_.*Level12.*\.png$/);
assert.throws(() => gibsTemplate('MODIS', '2026-01-01'), /Unknown/);
assert.equal(
wvsSnapshotUrl({
product: 'S30',
day: '2026-09-18',
box: BOX,
width: 256,
height: 256,
}),
'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&CRS=EPSG:4326&TIME=2026-09-18&BBOX=30.2,-97.8,30.3,-97.7&WIDTH=256&HEIGHT=256&FORMAT=image/png',
);
assert.throws(
() => wvsSnapshotUrl({ product: 'S30', day: 'x', box: null }),
TypeError,
);
});
test('thumbnails load the focused card, then the visible cards outward, then the margins', () => {
assert.deepEqual(thumbnailOrder(5, 3, 7, 20), [5, 6, 4, 7, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(19, 17, 19, 20), [19, 18, 17, 16, 15]);
assert.deepEqual(thumbnailOrder(50, 7, 3, 10), [7, 6, 5, 4, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(0, 0, 0, 0), []);
});
+265
View File
@@ -0,0 +1,265 @@
/**
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
* host the map stack offers. Against the basemap slot `a` splits left and
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
* host switch or a replaced image never strands one. Tile requests pass a
* small semaphore of our own; Cesium's global request limits stay untouched.
*/
import * as Cesium from 'cesium';
import { PRODUCTS, gibsTemplate } from './model.js';
const SLOT_IDS = ['a', 'b'];
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
/** Render reason for a deferred or settled tile request. */
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
/** Delay before the one frame that retries tiles Cesium itself deferred. */
export const TILE_RETRY_DELAY_MS = 250;
function clampAlpha(value) {
const alpha = Number(value);
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
}
const boxKey = (box) =>
[box.west, box.south, box.east, box.north].map(Number).join(',');
/**
* Gate a provider's `requestImage` through the semaphore. Returning
* `undefined` tells Cesium to retry on a later frame, but under
* `requestRenderMode` there is no later frame unless something asks — and a
* deferral must never ask for one itself: the retried tile would defer again
* and ask again, keeping the scene rendering every frame. So a request that
* finds our semaphore full asks for nothing (the request holding the slot
* asks for one frame when it settles), and a request the provider itself
* deferred goes through the one coalesced delayed retry.
*/
function admitTileRequests(provider, admission, requestRender, retry) {
const original = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (admission.inFlight >= admission.limit) return undefined;
admission.inFlight += 1;
const release = () => {
admission.inFlight -= 1;
};
let promise;
try {
promise = original(x, y, level, request);
} catch (error) {
release();
throw error;
}
if (!promise) {
release();
retry.schedule();
return undefined;
}
return Promise.resolve(promise).finally(() => {
release();
requestRender(TILE_RENDER_REASON);
});
};
return provider;
}
/**
* Create the renderer.
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
*/
export function createRecentImageryRenderer({
cesium = Cesium,
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
} = {}) {
let host = NO_HOST;
let destroyed = false;
const admission = { limit: maxTileRequests, inFlight: 0 };
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
const owned = { a: null, b: null };
// One timer for both slots: any number of upstream deferrals while it is
// armed share the same single retry frame.
let retryTimer = null;
const retry = {
schedule() {
if (retryTimer !== null) return;
retryTimer = setTimeoutImpl(() => {
retryTimer = null;
if (!destroyed) requestRender(TILE_RENDER_REASON);
}, TILE_RETRY_DELAY_MS);
},
cancel() {
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
retryTimer = null;
},
};
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
tilingScheme: new cesium.WebMercatorTilingScheme(),
rectangle: cesium.Rectangle.fromDegrees(
box.west,
box.south,
box.east,
box.north,
),
credit: 'NASA GIBS',
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
}
function removeOwned(slotId) {
const record = owned[slotId];
if (!record) return false;
owned[slotId] = null;
try {
record.collection.remove(record.layer, true);
} catch {
/* the collection is already gone */
}
return true;
}
function applyLook(record) {
record.layer.alpha = record.alpha;
record.layer.splitDirection =
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
}
function mount(slotId, { candidate, box, alpha, split }) {
const layer = host.collection.addImageryProvider(
createProvider(candidate, box),
);
owned[slotId] = {
layer,
collection: host.collection,
candidate,
box,
alpha,
split,
};
applyLook(owned[slotId]);
}
return {
/**
* Drape a candidate into a slot. The same candidate on the same host only
* updates its look; anything else replaces the slot's layer.
* @param {'a'|'b'} slotId
* @param {object} candidate
* @param {object} box Degrees box.
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
* @returns {boolean} Whether a layer is now draped for the slot.
*/
showSlot(
slotId,
candidate,
box,
{ alpha = 1, splitDirection = 'none' } = {},
) {
if (destroyed || !candidate?.key || !box) return false;
if (!host.collection) {
removeOwned(slotId);
return false;
}
const current = owned[slotId];
const look = { alpha: clampAlpha(alpha), split: splitDirection };
if (
current &&
current.collection === host.collection &&
current.candidate.key === candidate.key &&
boxKey(current.box) === boxKey(box)
) {
if (current.alpha !== look.alpha || current.split !== look.split) {
Object.assign(current, look);
applyLook(current);
requestRender('recent-imagery-look');
}
return true;
}
removeOwned(slotId);
mount(slotId, { candidate, box, ...look });
requestRender('recent-imagery-show');
return true;
},
/** @param {'a'|'b'} slotId */
hideSlot(slotId) {
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
},
/**
* @param {'a'|'b'} slotId
* @param {number} alpha 0–1
*/
setAlpha(slotId, alpha) {
const record = owned[slotId];
const next = clampAlpha(alpha);
if (!record || record.alpha === next) return;
record.alpha = next;
applyLook(record);
requestRender('recent-imagery-alpha');
},
/**
* Move every owned layer to a new host. Layers are rebuilt there, never
* re-parented, so the old collection is left as it was found.
* @param {{ collection: object | null, kind: string }} nextHost
*/
rebind(nextHost) {
host = nextHost || NO_HOST;
let changed = false;
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
if (!record || record.collection === host.collection) continue;
removeOwned(slotId);
changed = true;
if (host.collection && !destroyed) mount(slotId, record);
}
if (changed) requestRender('recent-imagery-rebind');
},
/** What is draped right now, per slot. */
getOwned() {
const out = {};
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
out[slotId] = record
? {
key: record.candidate.key,
kind: host.kind,
alpha: record.alpha,
split: record.split,
}
: null;
}
return out;
},
/** Number of layers this renderer owns (never above two). */
ownedCount() {
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
},
/** Remove everything and refuse further work. */
destroy() {
if (destroyed) return;
destroyed = true;
retry.cancel();
let changed = false;
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
host = NO_HOST;
if (changed) requestRender('recent-imagery-destroy');
},
};
}
+210
View File
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TILE_RENDER_REASON,
TILE_RETRY_DELAY_MS,
createRecentImageryRenderer,
} from './rendering.js';
import { BOX, manualTimers } from './testDoubles.mjs';
/** A provider whose tile requests stay pending until the test settles them. */
class UrlTemplateImageryProvider {
constructor(options) {
this.options = options;
this.calls = [];
this.pending = [];
}
requestImage(x, y, level) {
this.calls.push([x, y, level]);
return new Promise((resolve) => this.pending.push(resolve));
}
}
function fakeCesium(requestImage) {
class Provider extends UrlTemplateImageryProvider {}
if (requestImage) Provider.prototype.requestImage = requestImage;
return {
UrlTemplateImageryProvider: Provider,
WebMercatorTilingScheme: class {},
Rectangle: {
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
},
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
};
}
function fakeCollection() {
const layers = [];
return {
layers,
addImageryProvider(provider) {
const layer = { provider, alpha: 1, splitDirection: 0 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
layer.destroyed = destroy;
},
};
}
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
const globe = fakeCollection();
renderer.rebind({ collection: globe, kind: 'globe' });
const tileFrames = () =>
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
return { renderer, globe, renders, timers, tileFrames };
}
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
const { renderer, globe, renders } = fixture();
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
const [layer] = globe.layers;
assert.match(
layer.provider.options.url,
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
);
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
assert.equal(layer.provider.options.credit, 'NASA GIBS');
assert.deepEqual(layer.provider.options.rectangle, BOX);
assert.equal(layer.alpha, 0.8);
assert.deepEqual(renders, ['recent-imagery-show']);
assert.deepEqual(renderer.getOwned().a, {
key: 'S30:2026-09-18',
kind: 'globe',
alpha: 0.8,
split: 'none',
});
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
assert.equal(layer.splitDirection, -1);
// A different day replaces the layer in its own collection.
renderer.showSlot('a', L30, BOX);
assert.equal(layer.destroyed, true);
assert.equal(globe.layers.length, 1);
// VIIRS is a level-9 JPEG overview with no alpha channel.
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
assert.equal(globe.layers[1].splitDirection, 1);
renderer.setAlpha('b', 7);
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
});
test('rapid replacement never owns more than two layers', () => {
const { renderer, globe } = fixture();
const days = [S30, L30, VIIRS];
for (let i = 0; i < 10; i += 1) {
renderer.showSlot('a', days[i % 3], BOX);
renderer.showSlot('b', days[(i + 1) % 3], BOX);
if (i % 4 === 0) renderer.hideSlot('a');
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
}
renderer.hideSlot('a');
renderer.hideSlot('b');
assert.equal(globe.layers.length, 0);
});
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
const { renderer, globe } = fixture();
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
const tileset = fakeCollection();
renderer.rebind({ collection: tileset, kind: 'tileset' });
assert.equal(globe.layers.length, 0);
assert.deepEqual(
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
[
[0.6, -1],
[1, 1],
],
);
assert.equal(renderer.getOwned().a.kind, 'tileset');
renderer.rebind({ collection: null, kind: 'none' });
assert.equal(tileset.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false);
renderer.rebind({ collection: globe, kind: 'globe' });
renderer.showSlot('a', S30, BOX);
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
});
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
});
renderer.showSlot('a', S30, BOX);
const { provider } = globe.layers[0];
const first = provider.requestImage(0, 0, 1);
assert.ok(first instanceof Promise);
for (let i = 1; i <= 120; i += 1)
assert.equal(provider.requestImage(i, 0, 1), undefined);
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
assert.equal(timers.armed(), 0);
provider.pending[0]('tile');
await first;
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
});
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
let throwNext = false;
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
requestImage() {
if (throwNext) throw new Error('boom');
return undefined;
},
});
renderer.showSlot('a', S30, BOX);
renderer.showSlot('b', L30, BOX);
for (const { provider } of globe.layers)
for (let i = 0; i < 40; i += 1)
assert.equal(provider.requestImage(i, 0, 0), undefined);
// Every deferral released its slot (the limit is one), and none asked
// for a frame of its own: one timer holds the single retry.
assert.equal(tileFrames(), 0);
assert.equal(timers.armed(), 1);
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
timers.flush();
assert.equal(tileFrames(), 1);
// A throwing provider releases its slot too.
throwNext = true;
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
throwNext = false;
globe.layers[0].provider.requestImage(0, 0, 0);
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
renderer.destroy();
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
assert.equal(tileFrames(), 1);
});
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
const { renderer, globe, renders } = fixture();
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
renderer.hideSlot('b');
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
assert.equal(globe.layers[0].splitDirection, -1);
assert.equal(renderer.getOwned().b, null);
const [layer] = globe.layers;
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
+368
View File
@@ -0,0 +1,368 @@
/**
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
* responses, and the Cesium/viewer/document fakes the box tool needs.
*/
import { thumbnailOrder } from './model.js';
export const BOX = Object.freeze({
west: -97.8,
south: 30.2,
east: -97.7,
north: 30.3,
});
export const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Timers that fire only when the test says so. */
export function manualTimers() {
const pending = new Map();
const cleared = [];
let nextId = 1;
return {
pending,
cleared,
setTimeoutImpl(fn, ms) {
const id = nextId++;
pending.set(id, { fn, ms });
return id;
},
clearTimeoutImpl(id) {
cleared.push(id);
pending.delete(id);
},
/** Fire every armed timer, in order. */
flush() {
const entries = [...pending.values()];
pending.clear();
for (const entry of entries) entry.fn();
},
armed: () => pending.size,
};
}
/** A `fetch` response with case-insensitive headers. */
export function response({
ok = true,
status = ok ? 200 : 500,
json = {},
headers = {},
blob = { size: 1 },
} = {}) {
const lower = new Map(
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
);
return {
ok,
status,
headers: { get: (name) => lower.get(name.toLowerCase()) ?? null },
json: async () => json,
blob: async () => blob,
};
}
const EVENT_TYPES = {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
LEFT_DOUBLE_CLICK: 'dblclick',
};
/**
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
*/
export function boxToolFakes() {
const handlers = [];
class ScreenSpaceEventHandler {
constructor(canvas) {
this.canvas = canvas;
this.actions = new Map();
this.destroyed = false;
handlers.push(this);
}
setInputAction(fn, type) {
this.actions.set(type, fn);
}
getInputAction(type) {
return this.actions.get(type);
}
removeInputAction(type) {
this.actions.delete(type);
}
destroy() {
this.destroyed = true;
}
}
class CustomDataSource {
constructor(name) {
this.name = name;
const values = [];
this.entities = {
values,
add: (entity) => (values.push(entity), entity),
removeAll: () => values.splice(0),
};
}
}
const color = { withAlpha: (alpha) => ({ alpha }) };
const cesium = {
ScreenSpaceEventHandler,
ScreenSpaceEventType: EVENT_TYPES,
CustomDataSource,
CallbackProperty: class {},
PolylineDashMaterialProperty: class {},
ClassificationType: { BOTH: 'both' },
Color: { fromCssColorString: () => color },
Rectangle: { fromDegrees: () => ({}) },
Cartesian3: { fromDegreesArray: (values) => values },
};
const stock = new ScreenSpaceEventHandler('stock');
const originalClick = () => 'select';
const originalDouble = () => 'track';
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
const sources = [];
const viewer = {
renders: 0,
scene: {
canvas: { clientWidth: 1000, clientHeight: 500 },
screenSpaceCameraController: { enableInputs: true },
requestRender: () => {
viewer.renders += 1;
},
},
screenSpaceEventHandler: stock,
dataSources: {
sources,
add: (source) => (sources.push(source), Promise.resolve(source)),
remove: (source) => sources.splice(sources.indexOf(source), 1),
},
};
const listeners = [];
const classes = new Set();
const documentRef = {
listeners,
classes,
addEventListener: (type, fn) => listeners.push([type, fn]),
removeEventListener: (type, fn) => {
const index = listeners.findIndex((entry) => entry[1] === fn);
if (index >= 0) listeners.splice(index, 1);
},
body: {
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
},
};
const pickWorld = (_viewer, nx, ny) =>
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
/** The live (non-stock) canvas handler, if the tool has one bound. */
const handler = () =>
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
const fire = (type, event) =>
handler()?.actions.get(EVENT_TYPES[type])?.(event);
/** Dispatch a keydown to the tool's document listener. */
const key = (name) => {
const event = {
key: name,
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopImmediatePropagation() {
this.stopped = true;
},
};
listeners.find(([type]) => type === 'keydown')?.[1](event);
return event;
};
return {
cesium,
viewer,
documentRef,
pickWorld,
handler,
fire,
key,
types: EVENT_TYPES,
originalClick,
originalDouble,
};
}
// ---- the layer's injected parts --------------------------------------
export function candidate(product, day, cloud = 5) {
const granules =
product === 'VIIRS'
? []
: [
{
id: `${product}-${day}`,
product,
timeStart: `${day}T17:12:00Z`,
timeEnd: `${day}T17:14:00Z`,
cloud,
},
];
return {
key: `${product}:${day}`,
product,
day,
granules,
cloud: granules.length ? { min: cloud, max: cloud } : null,
timeRange: granules.length
? { start: granules[0].timeStart, end: granules[0].timeEnd }
: null,
availability: granules.length ? 'present' : 'unknown',
coverage: 'full',
};
}
// Strip order, newest first: V21 (unprobed), S18 (clear), L16 (cloudy), V15.
export const CANDIDATES = Object.freeze([
candidate('VIIRS', '2026-09-21'),
candidate('S30', '2026-09-18', 12),
candidate('L30', '2026-09-16', 60),
candidate('VIIRS', '2026-09-15'),
]);
export function fakeRenderer() {
const calls = [];
const owned = { a: null, b: null };
let host = { kind: 'none' };
let peak = 0;
return {
calls,
rebind(next) {
calls.push(['rebind', next.kind]);
host = next;
for (const slot of ['a', 'b'])
if (owned[slot]) owned[slot] = { ...owned[slot], kind: next.kind };
},
showSlot(slot, cand, box, look) {
if (owned[slot]?.key !== cand.key) calls.push(['show', slot, cand.key]);
owned[slot] = { key: cand.key, kind: host.kind, ...look };
peak = Math.max(peak, this.ownedCount());
return true;
},
hideSlot(slot) {
owned[slot] = null;
},
setAlpha(slot, alpha) {
calls.push(['alpha', slot, alpha]);
},
getOwned: () => ({ ...owned }),
ownedCount: () => ['a', 'b'].filter((slot) => owned[slot]).length,
peak: () => peak,
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeThumbnails() {
const calls = [];
const listeners = new Set();
const statuses = new Map();
/** Every key the layer asked to probe, in the order it asked. */
const requested = [];
/** Direct `request` calls: `[key, priority]`. */
const requests = [];
const ask = (key) => {
if (!requested.includes(key)) requested.push(key);
};
return {
calls,
requested,
requests,
requestOrdered(candidates, box, window) {
calls.push(['ordered', candidates.map((c) => c.key), window]);
const { focusIndex, firstVisible, lastVisible, extra } = window;
thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
).forEach((index) => ask(candidates[index].key));
},
request(candidate, box, priority) {
ask(candidate.key);
requests.push([candidate.key, priority]);
},
/** Settle every requested key still unknown, as the loader would. */
probeRequested(status = 'present') {
for (const key of requested)
if (!statuses.has(key)) this.probe(key, status);
},
clear() {
calls.push(['clear']);
},
get: (key) => ({ status: statuses.get(key) || 'unknown' }),
/** Record a probe result and tell the layer, as the loader does. */
probe(key, status) {
statuses.set(key, status);
for (const listener of listeners) listener(key);
},
/** Record a probe result without telling anyone yet. */
setStatus: (key, status) => statuses.set(key, status),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeCatalog() {
const searches = [];
return {
searches,
searchHls(request) {
return new Promise((resolve, reject) => {
request.signal?.addEventListener('abort', () =>
reject(new Error('aborted')),
);
searches.push({ request, resolve, reject });
});
},
resolveLast(candidates = CANDIDATES) {
searches.at(-1).resolve({ candidates, truncated: false, errors: [] });
},
};
}
/** A map controller whose lease settles when the test says so. */
export function fakeController({ refuse = false } = {}) {
const calls = [];
const controller = {
calls,
acquireImageryComparison(options) {
calls.push(['acquire', options.owner, options.switchPolicy]);
if (refuse) throw new Error('held by bhote');
let settle;
const ready = new Promise((resolve) => {
settle = resolve;
});
controller.lease = {
ready,
release: () => {
calls.push(['release']);
return Promise.resolve();
},
settle: (result = { status: 'ready' }) => settle(result),
};
return controller.lease;
},
};
return controller;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
* candidate day. The response's `Data-Present` header is the only proof that
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
* are decoded images: residency is least-recently-USED, where a request or a
* completed decode is a use and a `get` read never is (the panel reads every
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
const EMPTY = Object.freeze({
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
});
/**
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
size = 256,
} = {}) {
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
const entries = new Map();
const listeners = new Set();
const queue = [];
let inFlight = 0;
let touchCounter = 0;
let destroyed = false;
function notify(key) {
for (const listener of [...listeners]) {
try {
listener(key);
} catch (error) {
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
}
}
}
function touch(entry) {
touchCounter += 1;
entry.touched = touchCounter;
}
function revokeImage(entry) {
if (!entry.objectUrl) return;
try {
revokeObjectUrl(entry.objectUrl);
} catch {
/* a revoked URL is already gone */
}
entry.objectUrl = null;
}
function evictDecoded(keep) {
const decoded = [...entries.values()]
.filter((entry) => entry.objectUrl && entry !== keep)
.sort((a, b) => a.touched - b.touched);
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
for (const entry of decoded) {
if (count <= resident) break;
revokeImage(entry);
notify(entry.key);
count -= 1;
}
}
function enqueue(entry) {
if (!queue.includes(entry)) queue.push(entry);
pump();
}
async function run(entry) {
const controller = new AbortController();
entry.controller = controller;
entry.loading = true;
inFlight += 1;
let status = 'error';
let objectUrl = null;
let acquisitionTime = null;
let cancelled = false;
try {
const url = wvsSnapshotUrl({
product: entry.candidate.product,
day: entry.candidate.day,
box: entry.box,
width: size,
height: size,
});
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
const present = String(
response.headers?.get?.('Data-Present') ?? 'true',
).toLowerCase();
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
if (present === 'false') {
status = 'empty';
} else {
const blob = await response.blob();
if (controller.signal.aborted) cancelled = true;
else {
objectUrl = createObjectUrl(blob);
status = 'present';
}
}
}
} catch (error) {
if (controller.signal.aborted || error?.name === 'AbortError')
cancelled = true;
else status = 'error';
} finally {
inFlight -= 1;
entry.loading = false;
entry.controller = null;
}
// Every settlement frees a slot, so a cancelled fetch pumps too.
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
if (objectUrl) revokeObjectUrl(objectUrl);
pump();
return;
}
// A failed re-fetch of an evicted image keeps what the day proved.
if (status !== 'error' || entry.status !== 'present') {
entry.status = status;
entry.acquisitionTime = acquisitionTime;
}
entry.objectUrl = objectUrl;
touch(entry);
if (objectUrl) evictDecoded(entry);
notify(entry.key);
pump();
}
function pump() {
if (destroyed) return;
queue.sort((a, b) => a.priority - b.priority);
while (inFlight < limit && queue.length) {
const entry = queue.shift();
if (entries.get(entry.key) !== entry) continue;
void run(entry);
}
}
return {
/**
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
* already known is only touched (and re-prioritised while it waits); a
* known-present day whose image was evicted is fetched again.
* @param {object} candidate
* @param {object} box Degrees box.
* @param {number} [priority]
*/
request(candidate, box, priority = 0) {
if (destroyed || !candidate?.key || !box) return;
const key = candidate.key;
const existing = entries.get(key);
if (existing) {
touch(existing);
if (existing.loading) return;
if (existing.status === 'unknown') {
existing.priority = priority;
} else if (existing.status === 'present' && !existing.objectUrl) {
existing.priority = priority;
enqueue(existing);
}
return;
}
const entry = {
key,
candidate,
box,
priority,
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
controller: null,
touched: 0,
};
touch(entry);
entries.set(key, entry);
enqueue(entry);
},
/**
* Request the visible window of a strip in `thumbnailOrder` priority.
* @param {Array<object>} candidates
* @param {object} box
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
*/
requestOrdered(
candidates,
box,
{ focusIndex, firstVisible, lastVisible, extra },
) {
const order = thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
);
order.forEach((index, priority) => {
this.request(candidates[index], box, priority);
});
},
/**
* Abort every fetch in flight and forget every unsettled request. A
* known day whose image was being fetched again keeps what it knows.
*/
cancelAll() {
queue.length = 0;
for (const entry of [...entries.values()]) {
if (entry.loading) entry.controller?.abort();
if (entry.status === 'unknown') entries.delete(entry.key);
}
},
/** Cancel everything and revoke every decoded thumbnail. */
clear() {
this.cancelAll();
for (const entry of entries.values()) revokeImage(entry);
entries.clear();
},
/**
* Read a day's state. A read is not a use: it never changes residency.
* @param {string} key Candidate key.
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
*/
get(key) {
const entry = entries.get(key);
if (!entry) return EMPTY;
return {
status: entry.status,
objectUrl: entry.objectUrl,
acquisitionTime: entry.acquisitionTime,
loading: entry.loading,
};
},
/** @param {(key: string) => void} listener */
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
stats() {
return {
inFlight,
queued: queue.length,
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
.length,
tracked: entries.size,
};
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
listeners.clear();
},
};
}
@@ -0,0 +1,317 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createThumbnailLoader } from './thumbnails.js';
import { BOX, response, settle } from './testDoubles.mjs';
const candidate = (product, day) => ({
key: `${product}:${day}`,
product,
day,
});
/** A fetch whose responses the test releases, in any order. */
function fakeFetch() {
const calls = [];
const impl = (url, { signal } = {}) =>
new Promise((resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
calls.push({ url, signal, resolve });
});
const respond = (index, { ok = true, present = 'true' } = {}) =>
calls[index].resolve(
response({
ok,
headers: {
'Data-Present': present,
'Acquisition-Time': '2026-09-18T17:12:00Z',
},
}),
);
return { impl, calls, respond };
}
function fixture(options = {}) {
const fetch = fakeFetch();
const created = [];
const revoked = [];
const loader = createThumbnailLoader({
fetchImpl: fetch.impl,
createObjectUrl: () => {
created.push(`blob:${created.length + 1}`);
return created.at(-1);
},
revokeObjectUrl: (url) => revoked.push(url),
...options,
});
return { loader, fetch, created, revoked };
}
test('Data-Present decides the day: present with an object URL and sensing time, empty, or error', async () => {
const { loader, fetch, created } = fixture();
const changes = [];
loader.subscribe((key) => changes.push(key));
loader.request(candidate('S30', '2026-09-18'), BOX, 0);
loader.request(candidate('L30', '2026-09-16'), BOX, 1);
loader.request(candidate('VIIRS', '2026-09-15'), BOX, 2);
assert.equal(loader.get('S30:2026-09-18').loading, true);
assert.match(
fetch.calls[0].url,
/wvs\.earthdata\.nasa\.gov.*HLS_S30.*TIME=2026-09-18.*WIDTH=256&HEIGHT=256/,
);
fetch.respond(0);
fetch.respond(1, { present: 'false' });
fetch.respond(2, { ok: false });
await settle();
assert.deepEqual(loader.get('S30:2026-09-18'), {
status: 'present',
objectUrl: 'blob:1',
acquisitionTime: '2026-09-18T17:12:00Z',
loading: false,
});
assert.equal(loader.get('L30:2026-09-16').status, 'empty');
assert.equal(loader.get('VIIRS:2026-09-15').status, 'error');
assert.deepEqual(created, ['blob:1']);
assert.deepEqual(changes.sort(), [
'L30:2026-09-16',
'S30:2026-09-18',
'VIIRS:2026-09-15',
]);
assert.equal(loader.get('nope').status, 'unknown');
});
test('at most maxInFlight fetches run and the queue drains by priority', async () => {
const { loader, fetch } = fixture({ maxInFlight: 2 });
loader.request(candidate('S30', '2026-09-10'), BOX, 5);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-13'), BOX, 3);
// The pump is eager: the first two arrivals start at once, the rest queue.
assert.equal(fetch.calls.length, 2);
assert.match(fetch.calls[0].url, /TIME=2026-09-10/);
assert.match(fetch.calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 2,
queued: 2,
decoded: 0,
tracked: 4,
});
fetch.respond(0);
await settle();
assert.equal(fetch.calls.length, 3);
assert.match(fetch.calls[2].url, /TIME=2026-09-12/, 'priority 0 before 3');
// A repeat request for a queued key just re-prioritises it.
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(loader.stats().tracked, 4);
fetch.respond(1);
await settle();
assert.equal(fetch.calls.length, 4);
assert.match(fetch.calls[3].url, /TIME=2026-09-13/);
});
test('requestOrdered loads the focused card first, then outward, then the margins', () => {
const { loader, fetch } = fixture({ maxInFlight: 10 });
const candidates = Array.from({ length: 8 }, (_, i) =>
candidate('S30', `2026-09-${String(10 + i).padStart(2, '0')}`),
);
loader.requestOrdered(candidates, BOX, {
focusIndex: 3,
firstVisible: 2,
lastVisible: 5,
extra: 1,
});
const days = fetch.calls.map(
(call) => /TIME=2026-09-(\d\d)/.exec(call.url)[1],
);
assert.deepEqual(days, ['13', '14', '12', '15', '16', '11']);
});
test('decoded thumbnails are evicted least-recently-used and revoked; a read is not a use and an evicted day keeps what it learned', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 10,
maxDecoded: 2,
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 2);
fetch.respond(0);
fetch.respond(1);
await settle();
assert.equal(loader.stats().decoded, 2);
loader.get('S30:2026-09-10'); // reading the oldest does not make it recent
fetch.respond(2);
await settle();
assert.equal(loader.stats().decoded, 2);
assert.deepEqual(revoked, ['blob:1']);
const evicted = loader.get('S30:2026-09-10');
assert.equal(evicted.status, 'present', 'availability survives eviction');
assert.equal(evicted.objectUrl, null);
assert.equal(evicted.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(loader.stats().tracked, 3, 'the entry is kept');
assert.equal(loader.get('S30:2026-09-11').objectUrl, 'blob:2');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A repeat request IS a use, and re-fetches an evicted day without ever
// reporting it unknown; 11 is now the LRU and goes when 10 comes back.
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4, 'an evicted day is fetched again');
assert.equal(loader.get('S30:2026-09-10').status, 'present');
assert.equal(loader.get('S30:2026-09-10').loading, true);
fetch.respond(3);
await settle();
assert.equal(loader.get('S30:2026-09-10').objectUrl, 'blob:4');
assert.deepEqual(revoked, ['blob:1', 'blob:2']);
assert.equal(loader.get('S30:2026-09-11').status, 'present');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A day that already holds an image is not fetched twice.
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4);
});
test('reading every card in strip order (a snapshot) never decides who is evicted; evicted days stay known and come back on request', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 20,
maxDecoded: 9,
});
const candidates = Array.from({ length: 20 }, (_, i) =>
candidate('S30', `2026-09-${String(i + 1).padStart(2, '0')}`),
);
candidates.forEach((c, i) => loader.request(c, BOX, i));
assert.equal(fetch.calls.length, 20);
// Decode the strip back to front, leaving the newest card in flight.
for (let i = 19; i >= 1; i -= 1) fetch.respond(i);
await settle();
const holders = () =>
candidates.filter((c) => loader.get(c.key).objectUrl).map((c) => c.key);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
'the nine most recently decoded',
);
// Sweep every card in strip order, several times, as getSnapshot does.
for (let pass = 0; pass < 3; pass += 1)
for (const c of candidates) loader.get(c.key);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
);
// The last decode evicts the least recently DECODED card, not the first
// card the sweep happened to read.
fetch.respond(0);
await settle();
assert.deepEqual(
holders(),
candidates.slice(0, 9).map((c) => c.key),
);
assert.equal(revoked.length, 11);
for (const c of candidates) {
const entry = loader.get(c.key);
assert.equal(entry.status, 'present', `${c.key} stays present`);
assert.equal(entry.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(entry.loading, false);
}
// Re-requesting an evicted day fetches it again and it holds an image.
const evictedKey = candidates[15].key;
assert.equal(loader.get(evictedKey).objectUrl, null);
loader.request(candidates[15], BOX, 0);
assert.equal(fetch.calls.length, 21);
assert.equal(loader.get(evictedKey).status, 'present');
fetch.respond(20);
await settle();
assert.ok(loader.get(evictedKey).objectUrl);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
});
test('cancelAll aborts in-flight fetches and a late settle never leaks an object URL', async () => {
const { loader, fetch, created, revoked } = fixture({ maxInFlight: 1 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
const signal = fetch.calls[0].signal;
loader.cancelAll();
assert.equal(signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 0,
});
await settle();
assert.equal(loader.stats().inFlight, 0);
assert.deepEqual(created, []);
assert.deepEqual(revoked, []);
assert.equal(loader.get('S30:2026-09-10').status, 'unknown');
});
test('clear and destroy revoke every resident thumbnail', async () => {
const { loader, fetch, revoked } = fixture({ maxInFlight: 10 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
fetch.respond(0);
fetch.respond(1);
await settle();
loader.clear();
assert.deepEqual(revoked.sort(), ['blob:1', 'blob:2']);
assert.equal(loader.stats().tracked, 0);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
fetch.respond(2);
await settle();
loader.destroy();
assert.equal(revoked.length, 3);
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(fetch.calls.length, 3, 'a destroyed loader fetches nothing');
});
test('a request queued behind an aborted fetch still starts once the abort settles', async () => {
// A response that arrives after the abort (the fetch ignored the signal).
const calls = [];
const fetchImpl = (url, { signal } = {}) =>
new Promise((resolve) => calls.push({ url, signal, resolve }));
const loader = createThumbnailLoader({
fetchImpl,
maxInFlight: 1,
createObjectUrl: () => 'blob:x',
revokeObjectUrl: () => {},
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.clear();
loader.request(candidate('S30', '2026-09-11'), BOX, 0);
assert.equal(calls[0].signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 1,
decoded: 0,
tracked: 1,
});
calls[0].resolve({
ok: true,
headers: { get: () => null },
blob: async () => ({}),
});
await settle();
assert.equal(calls.length, 2, 'the replacement request was pumped');
assert.match(calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
// The same sequence when the fetch rejects with AbortError instead.
const rejecting = fixture({ maxInFlight: 1 });
rejecting.loader.request(candidate('S30', '2026-09-10'), BOX, 0);
rejecting.loader.clear();
rejecting.loader.request(candidate('S30', '2026-09-11'), BOX, 0);
await settle();
assert.equal(rejecting.fetch.calls.length, 2);
assert.deepEqual(rejecting.loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
});
+5
View File
@@ -8,6 +8,7 @@ import { createTesting } from './testing.js';
import { createInteraction } from './interaction.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createRecords } from './records.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
@@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) {
parts.catalog = createCatalog(context);
parts.labels = createLabels(context);
parts.orbits = createOrbits(context);
parts.records = createRecords(context);
parts.rendering = createRendering(context);
parts.tracking = createTracking(context);
parts.testing = createTesting(context);
@@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) {
applySatellitePointFocusDeemphasis:
parts.rendering.applySatellitePointFocusDeemphasis,
getNextIssPass: parts.orbits.getNextIssPass,
getNextSatellitePass: parts.orbits.getNextSatellitePass,
resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass,
getAnalystRecords: parts.records.getAnalystRecords,
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
+84 -6
View File
@@ -7,7 +7,7 @@ import {
degreesLat,
twoline2satrec,
} from 'satellite.js';
import { findNextIssPass } from '../../data/issPass.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -136,22 +136,98 @@ export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Next ISS pass for an observer. Requires the catalog to have loaded (the
* satellites layer enabled at least once this session).
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}}
*/
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
const sat = layerState._catalog.get(ISS_NORAD);
function getNextIssPass({
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
}) {
return getNextSatellitePass(ISS_NORAD, {
latDeg,
lonDeg,
minElevDeg,
requireVisible,
fromMs,
});
}
/**
* Next pass of any catalog satellite for an observer.
* @param {number} noradId NORAD satellite catalog ID
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
*/
function getNextSatellitePass(
noradId,
{
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
},
) {
const id = Number(noradId);
if (!Number.isSafeInteger(id) || id <= 0) return { status: 'no-tle' };
const sat = layerState._catalog.get(id);
if (!sat || !sat.satrec) return { status: 'no-tle' };
const pass = findNextIssPass({
const pass = findNextSatellitePass({
satrec: sat.satrec,
latDeg,
lonDeg,
fromMs: Date.now(),
fromMs,
minElevDeg,
requireVisible,
});
return pass ? { status: 'ok', pass } : { status: 'none' };
}
/** Resolve only loaded catalog identities; ambiguous names never pick a first row. */
function resolveSatelliteForPass(query) {
const text = String(query ?? '').trim();
if (!text || text.length > 120) return { status: 'not-found' };
const exactId = /^\d+$/.test(text) ? Number(text) : null;
if (exactId !== null) {
const sat = layerState._catalog.get(exactId);
return sat?.satrec
? { status: 'ok', noradId: exactId, name: sat.name }
: { status: 'not-found' };
}
const q = text.toLowerCase();
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
const exact = rows.filter(
([, sat]) => String(sat.name).toLowerCase() === q,
);
const matches = exact.length
? exact
: rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
if (!matches.length) return { status: 'not-found' };
if (matches.length > 1)
return {
status: 'ambiguous',
candidates: matches
.slice(0, 8)
.map(([noradId, sat]) => ({ noradId, name: sat.name })),
totalMatches: matches.length,
};
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
}
/**
* Score a mission-to-catalog name match. Compact identifier containment handles
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
@@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
orbitalPeriodSeconds,
computeOrbitPath,
getNextIssPass,
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import { ISS_NORAD } from './policy.js';
/**
* Map one CelesTrak catalog satellite to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. `id` is the display name (same
* convention as vessels); `noradId` is the track_entity key.
* @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}.
* @returns {{id: string, noradId: string|null, name: string|null,
* lat: number|null, lon: number|null, altitudeM: number|null,
* speedMps: number|null, satelliteClass: string|null, group: string|null}}
*/
export function mapAnalystRecord(raw) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
const noradNum =
raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
const noradId = Number.isFinite(noradNum)
? String(Math.trunc(noradNum))
: text(raw?.noradId);
const name = text(raw?.name);
const group = text(raw?.group);
const isIss = noradNum === ISS_NORAD;
return {
id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'),
noradId,
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
altitudeM: num(raw?.altitudeM),
speedMps: num(raw?.speedMps),
// Empty records stay class-less; the class table's fallback is VISUAL and
// must not leak onto a row that has no satellite identity.
satelliteClass:
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
group,
};
}
/** Create on-demand analyst snapshots owned by one layer instance. */
export function createRecords({ state, parts }) {
function analystGeo(noradId, sat, now) {
const pos = sat?.satrec
? parts.orbits.propagatePosition(sat.satrec, now)
: null;
if (
pos &&
Number.isFinite(pos.latitude) &&
Number.isFinite(pos.longitude)
) {
return {
lat: pos.latitude,
lon: pos.longitude,
altitudeM: pos.altitude,
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
};
}
const point = state._points.get(noradId);
if (!point?.position) return null;
const carto = Cesium.Cartographic.fromCartesian(point.position);
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
speedMps: null,
};
}
function getAnalystRecords(maxCount = 2000) {
if (!state._enabled || !state._catalog.size) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const now = new Date();
const result = [];
const emit = (noradId, sat) => {
if (result.length >= limit) return false;
const pos = analystGeo(noradId, sat, now);
if (!pos) return true;
result.push(
mapAnalystRecord({
noradId,
name: sat?.name,
group: sat?.group,
lat: pos.lat,
lon: pos.lon,
altitudeM: pos.altitudeM,
speedMps: pos.speedMps,
}),
);
return result.length < limit;
};
for (const [noradId, sat] of state._catalog) {
if (sat?.group === 'dense') continue;
if (!emit(noradId, sat)) break;
}
if (result.length < limit) {
for (const [noradId, sat] of state._catalog) {
if (sat?.group !== 'dense') continue;
if (!emit(noradId, sat)) break;
}
}
return result;
}
return { getAnalystRecords };
}
+108
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { createSatelliteSource } from './source.js';
import { createSatellitesLayer } from './index.js';
@@ -56,3 +57,110 @@ test('satellite factories keep control state separate and construct without requ
assert.equal(second.getParams().showPoints, true);
assert.notEqual(first.getStats(), second.getStats());
});
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
const source = { readGroup() {} };
const services = Object.fromEntries(
[
'picking',
'focus',
'readout',
'overlays',
'context',
'render',
'layerState',
].map((key) => [key, {}]),
);
services.layerState.isExplicitLayerStateOrigin = () => false;
const layer = createSatellitesLayer({ source, services });
// Cold catalog (no satellites loaded yet)
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
status: 'no-tle',
});
assert.deepEqual(
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// String NORAD ID coercion
assert.deepEqual(
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Invalid / non-finite ID handling
assert.deepEqual(
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
assert.deepEqual(
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Populated catalog: verify numeric and string coercion succeed
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 satrec = twoline2satrec(L1, L2);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
});
const FROM_MS = 1221868800000;
const numericResult = layer.getNextSatellitePass(25544, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(numericResult.status, 'ok');
assert.ok(numericResult.pass);
const stringResult = layer.getNextSatellitePass('25544', {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(stringResult.status, 'ok');
assert.deepEqual(stringResult, numericResult);
const issResult = layer.getNextIssPass({
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(issResult.status, 'ok');
assert.deepEqual(issResult, numericResult);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
neighbours: [
{ noradId: 10, name: 'STARLINK ONE', satrec },
{ noradId: 11, name: 'STARLINK TWO', satrec },
],
});
assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous');
assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2);
assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10);
assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found');
layer._clearDenseCatalogStateForTest();
assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found');
// Non-existent satellite ID in populated catalog
assert.deepEqual(
layer.getNextSatellitePass(99999, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
}),
{ status: 'no-tle' },
);
});
+23
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createTrafficSource } from './source.js';
import { clampBoundsAroundCenter } from '../../data/trafficBounds.js';
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
const fixture = readFileSync(
new URL(
@@ -87,6 +88,28 @@ test('road requests have finite bounds and retain the two-pass query', async ()
/residential/,
);
});
test('antimeridian clamps produce road bounds accepted on either side', async () => {
const calls = [];
const source = createTrafficSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"elements":[]}');
},
});
for (const centerLon of [179.99, -179.99]) {
const clamped = clampBoundsAroundCenter(
{
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
},
{ lat: 0, lon: centerLon },
);
await source.requestRoads(clamped);
}
assert.equal(calls.length, 2);
});
test('malformed availability is an unavailable source rather than a keyless response', async () => {
const source = createTrafficSource({
fetchImpl: async () => new Response('{}'),
+13 -2
View File
@@ -54,7 +54,14 @@ export function createSelection({
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
const siblingOwned =
pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
// Cyclone geometry is ambient context behind painted cards. Its handler
// yields to the same foreground AIS hit. Keep moving-contact precedence
// unchanged: those handlers may issue their own camera command.
const cycloneBackground =
siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
if (siblingOwned && !cycloneBackground) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
// usually terrain behind the card. Resolve against the host's current
@@ -64,10 +71,14 @@ export function createSelection({
click.position?.x,
click.position?.y,
{
sourceId: VESSEL_OVERLAY_SOURCE_ID,
...(cycloneBackground
? {}
: { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
},
)
: null;
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
return;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
+218
View File
@@ -0,0 +1,218 @@
export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** Transient observed time. Products must honor the signal before installing a frame. */
export function createWeatherClock({
now = Date.now,
setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout,
} = {}) {
const registry = new Map();
const listeners = new Set();
let mode = 'latest';
let target = null;
let playing = false;
let generation = 0;
let timer = null;
let pending = null;
let destroyed = false;
const timesFor = (product) =>
[...new Set(product.getTimes())]
.filter((time) => Number.isFinite(Date.parse(time)))
.sort((a, b) => Date.parse(a) - Date.parse(b));
const getTimeline = () =>
[
...new Set(
[...registry.values()].flatMap(({ product }) =>
product.isSuspended() ? [] : timesFor(product),
),
),
].sort((a, b) => Date.parse(a) - Date.parse(b));
function selectFor(id, time) {
const product = registry.get(id)?.product;
if (!product) return null;
const at = Date.parse(time);
return (
timesFor(product).findLast((candidate) => {
const age = at - Date.parse(candidate);
return age >= 0 && age <= product.maxGapMs;
}) ?? null
);
}
const selectedFor = (product) =>
mode === 'latest'
? (timesFor(product).at(-1) ?? null)
: selectFor(product.id, target);
const getState = () => ({
mode,
target,
playing,
timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
shown: product.getShownTime() ?? null,
selected: selectedFor(product),
suspended: Boolean(product.isSuspended()),
})),
});
const notify = () => {
if (!destroyed) for (const listener of listeners) listener(getState());
};
const cancelTimer = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
playing = false;
notify();
return;
}
const owner = generation;
timer = setTimeout(() => {
timer = null;
if (destroyed || !playing || owner !== generation) return;
const timeline = getTimeline();
if (timeline.length < 2) {
api.pause();
return;
}
const next = timeline.find(
(time) => Date.parse(time) > Date.parse(target),
);
void api.setTarget(next ?? timeline[0]);
}, 2000);
}
function transition() {
if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation;
if (!canPlay()) playing = false;
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
entry.controller = new AbortController();
const { signal } = entry.controller;
const time = selectedFor(entry.product);
return Promise.resolve().then(() => {
if (signal.aborted || owner !== generation || destroyed) return false;
return entry.product.apply(time, { signal });
});
});
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
pending = null;
if (!canPlay()) playing = false;
notify();
schedule();
return true;
});
pending = result;
notify();
return result;
}
const api = {
register(product) {
if (destroyed) return () => {};
if (registry.has(product.id))
throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null };
registry.set(product.id, entry);
void api.refresh();
return () => {
if (registry.get(product.id) !== entry) return;
entry.controller?.abort();
registry.delete(product.id);
void api.refresh();
};
},
getTimeline,
selectFor,
setTarget(time) {
if (destroyed) return Promise.resolve(false);
if (!Number.isFinite(Date.parse(time)))
throw new TypeError('Weather target must be a UTC time');
const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
mode = 'history';
target = normalized;
return transition();
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
return Promise.resolve(false);
api.pause();
const timeline = getTimeline();
if (!timeline.length) return Promise.resolve(false);
const at = Date.parse(target ?? timeline.at(-1));
const next =
direction < 0
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
return api.setTarget(next);
},
latest() {
if (destroyed) return Promise.resolve(false);
mode = 'latest';
target = null;
playing = false;
return transition();
},
play() {
if (destroyed || playing || !canPlay())
return pending ?? Promise.resolve(false);
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
return api.setTarget(
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
);
}
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
if (!playing) return;
playing = false;
notify();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
refresh() {
if (destroyed) return Promise.resolve(false);
if (!canPlay()) {
playing = false;
cancelTimer();
}
if (mode === 'history') return transition();
notify();
return Promise.resolve(true);
},
getState,
subscribe(listener) {
if (!destroyed) listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
++generation;
playing = false;
cancelTimer();
for (const entry of registry.values()) entry.controller?.abort();
registry.clear();
listeners.clear();
pending = null;
},
};
return api;
}
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherClock,
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
function product(id, minutes, maxGapMs = RADAR_MAX_GAP_MS) {
const p = {
id,
maxGapMs,
times: minutes.map(time),
shown: null,
suspended: false,
calls: [],
getTimes: () => p.times,
getShownTime: () => p.shown,
isSuspended: () => p.suspended,
async apply(selected, { signal }) {
p.calls.push({ selected, signal });
if (signal.aborted) return false;
p.shown = selected;
return true;
},
};
return p;
}
function harness(t) {
let at = Date.parse(time(20));
const timers = new Map();
let id = 0;
const clock = createWeatherClock({
now: () => at,
setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
});
t.after(() => clock.destroy());
return {
clock,
timers,
tick(ms) {
at += ms;
for (const [id, timer] of [...timers]) {
if (timer.due > at) continue;
timers.delete(id);
timer.fn();
}
},
};
}
test('union excludes suspended products; nearest selection never uses future or out-of-gap frames', (t) => {
const { clock } = harness(t);
const radar = product('radar', [10, 0, 10]);
const regional = product('regional', [5, 10]);
const global = product('global', [-180, -60], GLOBAL_INFRARED_MAX_GAP_MS);
const hidden = product('hidden', [99]);
hidden.suspended = true;
for (const p of [radar, regional, global, hidden]) clock.register(p);
assert.deepEqual(clock.getTimeline(), [-180, -60, 0, 5, 10].map(time));
assert.equal(clock.selectFor('radar', time(9)), time(0));
assert.equal(clock.selectFor('radar', time(-1)), null);
assert.equal(clock.selectFor('radar', time(40)), time(10));
assert.equal(clock.selectFor('radar', time(41)), null);
assert.equal(clock.selectFor('global', time(120)), time(-60));
assert.equal(clock.selectFor('global', time(121)), null);
assert.equal(clock.selectFor('missing', time(10)), null);
assert.equal(REGIONAL_INFRARED_MAX_GAP_MS, 30 * 60_000);
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
});
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]);
const satellite = product('satellite', [5, 15]);
const lightning = product('lightning', [15]);
for (const p of [radar, satellite, lightning]) clock.register(p);
await clock.setTarget(time(10));
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(10), time(5), null],
);
assert.deepEqual(
clock.getState().products.map(({ selected }) => selected),
[time(10), time(5), null],
);
await clock.latest();
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(20), time(15), time(15)],
);
assert.equal(clock.getState().mode, 'latest');
assert.equal(clock.getState().target, null);
assert.equal(clock.getState().playing, false);
});
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
const { clock, timers } = harness(t);
const p = product('radar', [0, 10, 20]);
const loads = [];
p.apply = (selected, { signal }) =>
new Promise((resolve) => {
loads.push({
selected,
signal,
finish() {
if (!signal.aborted) p.shown = selected;
resolve(!signal.aborted);
},
});
});
clock.register(p);
let notifications = 0;
clock.subscribe(() => notifications++);
const old = clock.setTarget(time(0));
await flush();
const current = clock.setTarget(time(10));
await flush();
assert.equal(loads[0].signal.aborted, true);
loads[1].finish();
assert.equal(await current, true);
const count = notifications;
loads[0].finish();
assert.equal(await old, false);
assert.equal(notifications, count);
assert.equal(p.shown, time(10));
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
});
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
const { clock } = harness(t);
clock.register(product('radar', [0, 10, 20]));
clock.register(product('satellite', [5, 15]));
await clock.step(-1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(12));
await clock.step(-1);
assert.equal(clock.getState().target, time(10));
await clock.setTarget(time(12));
await clock.step(1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(0));
await clock.step(-1);
assert.equal(clock.getState().target, time(0));
await clock.setTarget(time(20));
await clock.step(1);
assert.equal(clock.getState().target, time(20));
});
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
const { clock, tick, timers } = harness(t);
const a = product('radar', [0, 10]);
const b = product('satellite', [5]);
const loads = [];
b.apply = () =>
new Promise((resolve, reject) => loads.push({ resolve, reject }));
clock.register(a);
clock.register(b);
const start = clock.play();
await flush();
assert.equal(clock.getState().target, time(10));
tick(10_000);
assert.equal(timers.size, 0);
assert.equal(a.calls.length, 1);
loads[0].reject(new Error('unavailable'));
await start;
assert.equal(timers.size, 1);
tick(1999);
assert.equal(clock.getState().target, time(10));
tick(1);
await flush();
assert.equal(clock.getState().target, time(0));
assert.equal(timers.size, 0);
tick(10_000);
assert.equal(clock.getState().target, time(0));
loads[1].resolve(true);
await flush();
tick(2000);
await flush();
assert.equal(clock.getState().target, time(5));
clock.pause();
loads[2].resolve(false);
await flush();
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
});
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
const { clock, tick, timers } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.play();
await clock.play();
assert.equal(timers.size, 1);
clock.pause();
tick(2000);
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
await clock.togglePlay();
p.times = [time(0)];
await clock.refresh();
assert.equal(clock.getState().playing, false);
await clock.play();
assert.equal(clock.getState().playing, false);
p.times = [time(0), time(10)];
await clock.refresh();
await clock.play();
p.suspended = true;
await clock.refresh();
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(timers.size, 0);
});
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
const { clock } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.refresh();
assert.equal(p.calls.length, 0);
await clock.setTarget(time(10));
p.times = [time(0), time(20)];
await clock.refresh();
assert.equal(p.shown, time(0));
p.times = [time(20)];
await clock.refresh();
assert.equal(p.shown, null);
assert.equal(clock.getState().target, time(10));
});
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
const { clock, timers, tick } = harness(t);
const p = product('radar', [0, 10]);
const unregister = clock.register(p);
await clock.setTarget(time(0));
const b = product('satellite', [0, 5]);
clock.register(b);
await flush();
assert.equal(b.shown, time(0));
await clock.play();
unregister();
unregister();
await flush();
assert.equal(p.calls.at(-1).signal.aborted, true);
assert.deepEqual(
clock.getState().products.map(({ id }) => id),
['satellite'],
);
let notifications = 0;
const off = clock.subscribe(() => notifications++);
clock.pause();
off();
const before = notifications;
await clock.play();
assert.equal(notifications, before);
clock.destroy();
clock.destroy();
tick(2000);
assert.equal(timers.size, 0);
assert.equal(b.calls.at(-1).signal.aborted, true);
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false);
});
+15
View File
@@ -0,0 +1,15 @@
/** Resolve the visible surface that can own weather imagery without scene mutation. */
export function resolveImageryHost({ viewer, tileset }) {
if (viewer?.scene?.globe?.show === true)
return { collection: viewer.imageryLayers, kind: 'globe' };
if (tileset?.imageryLayers)
return { collection: tileset.imageryLayers, kind: 'tileset' };
return { collection: null, kind: 'none' };
}
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
/** Report imagery suspension without changing or detaching the resolved host. */
export function imageryHostStatus(host) {
return host.kind === 'none' ? NO_IMAGERY_HOST : null;
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveImageryHost,
imageryHostStatus,
NO_IMAGERY_HOST,
} from './imageryHost.js';
test('only a missing host suspends weather; 3D Tiles has no height gate', () => {
assert.equal(imageryHostStatus({ kind: 'tileset' }), null);
assert.equal(imageryHostStatus({ kind: 'globe' }), null);
assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST);
});
test('visible globe takes precedence over a tileset', () => {
const viewer = { imageryLayers: {}, scene: { globe: { show: true } } };
assert.deepEqual(
resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }),
{ collection: viewer.imageryLayers, kind: 'globe' },
);
});
test('hidden globe uses the supplied tileset imagery collection', () => {
const tileset = { imageryLayers: {} };
const viewer = { scene: { globe: { show: false } } };
assert.deepEqual(resolveImageryHost({ viewer, tileset }), {
collection: tileset.imageryLayers,
kind: 'tileset',
});
});
test('hidden globe without an imagery-capable tileset has no host', () => {
for (const tileset of [null, {}])
assert.deepEqual(
resolveImageryHost({
viewer: { scene: { globe: { show: false } } },
tileset,
}),
{ collection: null, kind: 'none' },
);
});
+17
View File
@@ -0,0 +1,17 @@
const priorities = new WeakMap();
/** Scalar context, infrared, radar, then lightning: independent of enable order. */
export function orderWeatherImagery(collection, layer, priority) {
priorities.set(layer, priority);
if (typeof collection.raiseToTop !== 'function') return;
const weather = [];
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (priorities.has(item)) weather.push(item);
}
const ordered = [...weather].sort(
(a, b) => priorities.get(a) - priorities.get(b),
);
if (weather.every((item, i) => item === ordered[i])) return;
for (const item of ordered) collection.raiseToTop(item);
}
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orderWeatherImagery } from './imageryOrder.js';
test('weather compositing remains consistent when scalar, observation and history arrive out of order', () => {
const base = { name: 'basemap' },
radar = { name: 'radar' },
cloud = { name: 'cloud' },
wind = { name: 'wind' },
lightning = { name: 'lightning' };
const items = [base];
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const [layer, priority] of [
[radar, 2],
[cloud, 1],
[lightning, 3],
[wind, 0],
]) {
items.push(layer);
orderWeatherImagery(collection, layer, priority);
}
assert.deepEqual(items, [base, wind, cloud, radar, lightning]);
const next = { name: 'new radar history frame' };
items.push(next);
orderWeatherImagery(collection, next, 2);
assert.deepEqual(items, [base, wind, cloud, radar, next, lightning]);
});
test('tileset imagery collection preserves scalar, infrared, radar and lightning order', async () => {
const { ImageryLayerCollection, ImageryLayer } = await import('cesium');
const tileset = { imageryLayers: new ImageryLayerCollection() };
const layers = Array.from({ length: 4 }, () => new ImageryLayer());
for (const priority of [3, 2, 0, 1]) {
tileset.imageryLayers.add(layers[priority]);
orderWeatherImagery(tileset.imageryLayers, layers[priority], priority);
}
assert.deepEqual(
layers.map((_, i) => tileset.imageryLayers.get(i)),
layers,
);
tileset.imageryLayers.destroy();
});
test('already ordered imagery adds and repeated ordering cause no collection churn', () => {
const items = [];
let raises = 0;
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
raises++;
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const priority of [0, 1, 1, 2, 3]) {
const layer = {};
items.push(layer);
orderWeatherImagery(collection, layer, priority);
orderWeatherImagery(collection, layer, priority);
}
assert.equal(raises, 0);
});
+727
View File
@@ -0,0 +1,727 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const STOPS = [
[10, '#00ecec'],
[20, '#0000f6'],
[30, '#00c800'],
[40, '#ffff00'],
[50, '#ff9000'],
[60, '#dc0000'],
[70, '#ff00ff'],
[80, '#05ede0'],
];
const LIGHTNING_STOPS = [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
];
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
/** A per-application observation layer, using the existing layer lifecycle and
* row controls. History is transient: shared links always open latest imagery. */
export function createWeatherLayer({
feed,
clock,
id = 'weather-radar',
cesium = Cesium,
createRendering = createWeatherRendering,
documentRef = globalThis.document,
eventTarget = globalThis.window,
matchMedia = globalThis.matchMedia?.bind(globalThis),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Weather requires a snapshot source');
const radar = id === 'weather-radar';
const lightning = id === 'weather-lightning';
const satellite = !radar && !lightning;
let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional';
let opacity = 'strong';
let infrared = 'filtered';
let viewer = null,
rendering = null,
manifest = null,
request = null,
listener = null;
let enabled = false,
loading = false,
playing = false,
followLatest = true;
let generation = 0,
timer = null,
error = null,
removeCamera = null;
let motion = null;
let runNavigation = null;
let imageryHost = null;
let hostCollection;
let hostHidden = false;
let hostStatus = null;
let unregisterClock = null;
let frameRequest = null;
let noFrame = false;
const maxGap = () =>
radar
? RADAR_MAX_GAP_MS
: lightning
? LIGHTNING_MAX_GAP_MS
: product === 'clouds'
? GLOBAL_INFRARED_MAX_GAP_MS
: REGIONAL_INFRARED_MAX_GAP_MS;
const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () =>
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
notify();
});
const observationDelayed = () =>
manifest?.latest &&
Date.now() - Date.parse(manifest.latest) >
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
const stop = () => {
if (clock) return;
playing = false;
rendering?.cancelPrefetch?.();
clearTimeout(timer);
timer = null;
};
const suspended = () => hostHidden || documentRef?.hidden || motion?.matches;
const onVisibility = () => {
if (suspended()) rendering?.cancelPrefetch?.();
if (clock) void clock.refresh();
else if (suspended()) stop();
notify();
};
function checkHost(resume = true) {
const host = getHost();
const status = imageryHostStatus(host);
const changed = host.collection !== hostCollection || hostStatus !== status;
hostCollection = host.collection;
hostStatus = status;
hostHidden = status !== null;
// Keep playback intent and the displayed time while the host is unavailable.
if (!changed || !rendering) return;
++generation;
rendering.rehome?.();
clearTimeout(timer);
timer = null;
loading = Boolean(request && !request.signal.aborted);
if (clock && enabled) {
if (resume) {
void clock.refresh();
if (!hostHidden && manifest && isLatest()) void show(manifest.latest);
}
notify();
return;
}
if (resume && enabled && !hostHidden && manifest) {
const time =
followLatest || !manifest.times.includes(shownTime())
? manifest.latest
: shownTime();
if (
time !== shownTime() ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
void show(time);
else schedule();
}
notify();
}
const onMapStackChanged = () => checkHost();
function schedule() {
if (clock) return;
clearTimeout(timer);
timer = null;
if (!enabled || !playing || suspended()) return;
timer = setTimeout(() => {
timer = null;
const times = manifest?.times || [];
const next = (times.indexOf(shownTime()) + 1) % times.length;
if (times[next]) void show(times[next]);
else stop();
}, 2000);
}
async function applyClockTime(time, { signal }) {
if (!enabled || signal.aborted) return false;
if (time !== null) return show(time, signal);
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = true;
rendering?.setHidden(true);
loading = Boolean(request && !request.signal.aborted);
notify();
return true;
}
function registerClock() {
if (!clock || unregisterClock || !rendering || !enabled) return;
unregisterClock = clock.register({
id,
get maxGapMs() {
return maxGap();
},
getTimes: () => manifest?.times ?? [],
getShownTime: shownTime,
apply: applyClockTime,
isSuspended: suspended,
});
}
async function warmNext(time) {
if (
!enabled ||
suspended() ||
!(clock ? clock.getState().playing : playing)
)
return;
const times = manifest?.times ?? [];
const next = times[(times.indexOf(time) + 1) % times.length];
if (!next || next === time) return;
try {
await rendering.prefetch?.(manifest, next, { infrared });
} catch {
// Speculative work must not change the displayed frame or playback state.
}
}
async function show(time, signal) {
checkHost(false);
if (!enabled || hostHidden || !manifest?.times?.includes(time))
return false;
signal?.throwIfAborted();
frameRequest?.abort();
const controller = new AbortController();
frameRequest = controller;
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const owner = ++generation;
loading = true;
notify();
try {
const ok = await rendering.setFrame(manifest, time, {
signal: controller.signal,
infrared,
});
if (owner !== generation || !enabled || controller.signal.aborted)
return false;
if (ok) {
noFrame = false;
rendering.setHidden?.(false);
void warmNext(time);
}
error = ok ? null : 'Frame unavailable; previous observation retained';
if (!ok) stop();
return ok;
} catch {
if (owner === generation && !controller.signal.aborted) {
error = 'Weather imagery unavailable';
stop();
}
return false;
} finally {
signal?.removeEventListener('abort', abort);
if (frameRequest === controller) frameRequest = null;
if (owner === generation) {
loading = Boolean(request && !request.signal.aborted);
notify();
if (clock && isLatest()) void clock.refresh();
schedule();
}
}
}
const layer = {
id,
name: radar
? 'Rain radar'
: lightning
? 'Lightning density'
: 'Satellite clouds',
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
source: 'NOAA nowCOAST · OBSERVED',
updateInterval: lightning ? 600_000 : 120_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({
viewer,
cesium,
getHost,
onChange: notify,
});
checkHost(false);
eventTarget?.addEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
motion = matchMedia?.('(prefers-reduced-motion: reduce)');
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
checkHost();
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
enabled = true;
registerClock();
},
disable() {
enabled = false;
unregisterClock?.();
unregisterClock = null;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
rendering?.clear();
manifest = null;
loading = false;
error = null;
followLatest = true;
},
async update(_viewer, { signal } = {}) {
if (!enabled) return false;
checkHost(false);
clearTimeout(timer);
timer = null;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => {
controller.abort(signal.reason);
stop();
};
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const snapshot = await feed.getSnapshot({
product,
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (snapshot.unavailable) {
error = 'Weather source unavailable; previous observation retained';
stop();
return true;
}
manifest = snapshot;
error = null;
if (clock) {
await clock.refresh();
if (
isLatest() &&
request === controller &&
!controller.signal.aborted
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
);
}
const time =
followLatest || !snapshot.times.includes(shownTime())
? snapshot.latest
: shownTime();
if (
shownTime() !== time ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Weather unavailable';
stop();
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = Boolean(rendering?.getDiagnostics().loading);
notify();
schedule();
}
}
},
setParams(params = {}) {
const infraredChanged =
satellite &&
['filtered', 'full'].includes(params.infrared) &&
params.infrared !== infrared;
if (infraredChanged) infrared = params.infrared;
if (['light', 'strong'].includes(params.opacity)) {
opacity = params.opacity;
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
}
if (
satellite &&
['clouds', 'clouds-regional'].includes(params.product) &&
params.product !== product
) {
product = params.product;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
manifest = null;
error = null;
loading = false;
followLatest = true;
rendering?.clear();
if (clock) void clock.refresh();
if (enabled) void layer.update(viewer);
}
if (infraredChanged && enabled && manifest) {
clearTimeout(timer);
timer = null;
if (clock && !isLatest()) void clock.refresh();
else void show(shownTime() || manifest.latest);
}
if (
params.focus === true &&
enabled &&
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
b.west,
b.south,
b.east,
b.north,
),
duration: motion?.matches ? 0 : 1.4,
}),
);
}
if (clock && enabled) {
if (params.play === true) void clock.togglePlay();
if (params.latest === true) void clock.latest();
if ([-1, 1].includes(params.step)) void clock.step(params.step);
}
if (
!clock &&
params.play === true &&
enabled &&
manifest?.times?.length > 1 &&
!suspended()
) {
playing = !playing;
followLatest = false;
if (playing) schedule();
else stop();
}
if (!clock && params.latest === true && manifest) {
stop();
followLatest = true;
void show(manifest.latest);
}
if (!clock && [-1, 1].includes(params.step) && manifest && !loading) {
stop();
followLatest = false;
const at = manifest.times.indexOf(shownTime());
const next = Math.max(
0,
Math.min(manifest.times.length - 1, at + params.step),
);
void show(manifest.times[next]);
}
notify();
},
getParams() {
return radar
? { opacity }
: satellite
? { product, opacity, infrared }
: { product, opacity };
},
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
const missing =
noFrame &&
shared?.mode === 'history' &&
shared.products.find((entry) => entry.id === id)?.selected === null
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
: null;
const time = shownTime();
const relation =
shared?.mode === 'history' && time
? Date.parse(time) === Date.parse(shared.target)
? ' · synced'
: Date.parse(time) < Date.parse(shared.target)
? ' · nearest'
: ''
: '';
const times = manifest?.times || [];
const index = times.indexOf(time);
const current = index >= 0;
const age = time
? Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60000))
: null;
const lag =
age === null
? ''
: age >= 60
? `${Math.floor(age / 60)}h ${age % 60}m ago`
: `${age}m ago`;
const diagnostic = rendering?.getDiagnostics();
const b = manifest?.bounds;
const canvas = viewer?.scene?.canvas;
const point =
canvas &&
viewer.camera.pickEllipsoid?.(
new cesium.Cartesian2(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
),
viewer.scene.globe.ellipsoid,
);
const camera = point
? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid)
: null;
const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0;
const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0;
const outside =
b &&
camera &&
(lon < b.west ||
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
const controls = {
readout: true,
summary: {
label: radar
? 'Rain radar · US'
: lightning
? 'Lightning density · 15 min'
: 'Satellite clouds',
coverage: radar
? 'CONUS'
: lightning
? 'Americas + Pacific'
: product === 'clouds'
? 'Global · 60°S–60°N'
: 'North America',
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail: time
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
: missing
? 'Observation unavailable'
: 'Waiting for observation',
status:
missing ||
hostStatus ||
error ||
diagnostic?.error ||
(observationDelayed()
? 'Source observations delayed'
: manifest?.stale
? 'Stale source'
: loading
? 'Loading next frame…'
: outside
? 'Map center outside coverage'
: null),
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
},
chips: [
...(satellite
? [
['clouds-regional', 'N. America'],
['clouds', 'Global'],
].map(([value, label]) => ({
id: value,
label,
active: product === value,
params: { product: value },
title:
value === 'clouds'
? 'Hourly global mosaic; usually 2–3 hours delayed'
: 'GOES regional clouds; approximately 5-minute updates',
}))
: []),
...(satellite
? [
{
id: 'filtered',
label: 'Clouds only',
active: infrared === 'filtered',
params: { infrared: 'filtered' },
title:
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
},
{
id: 'full',
label: 'Full',
active: infrared === 'full',
params: { infrared: 'full' },
title: 'The complete infrared image at the chosen opacity',
},
]
: []),
...['light', 'strong'].map((value) => ({
id: `opacity-${value}`,
label: value === 'light' ? 'Soft' : 'Vivid',
active: opacity === value,
params: { opacity: value },
title: 'Image opacity; does not alter the observed values',
})),
{
id: 'coverage',
label: radar
? 'View US radar'
: lightning
? 'View Americas & Pacific'
: 'View coverage',
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? `${label} strikes/km²/min ×10³ (15-minute density)`
: `${label} dBZ radar reflectivity`,
}))
: [],
info: hostHidden
? hostStatus
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
infoTitle: lightning
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
};
controls.summary.settings = [
...(satellite
? [
{
id: 'region',
label: 'REGION',
chips: controls.chips.filter(({ params }) => params.product),
},
{
id: 'image',
label: 'IMAGE',
chips: controls.chips.filter(({ params }) => params.infrared),
},
]
: []),
{
id: 'opacity',
label: 'OPACITY',
chips: controls.chips.filter(({ params }) => params.opacity),
},
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'coverage',
);
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? 'Observed' : 'History',
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
observedAt: shownTime(),
};
},
getDiagnostics() {
const shared = clock?.getState();
return {
...rendering?.getDiagnostics(),
playing: shared ? shared.playing : playing && !hostHidden,
followLatest: isLatest(),
...(shared
? {
clock: {
mode: shared.mode,
target: shared.target,
playing: shared.playing,
},
}
: {}),
historyFrames: manifest?.times?.length || 0,
timerActive: timer !== null,
};
},
destroy() {
layer.disable();
unsubscribeClock?.();
removeCamera?.();
removeCamera = null;
motion?.removeEventListener?.('change', onVisibility);
documentRef?.removeEventListener?.('visibilitychange', onVisibility);
eventTarget?.removeEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
runNavigation = null;
imageryHost = null;
viewer = null;
rendering = null;
listener = null;
},
};
return layer;
}
+23
View File
@@ -0,0 +1,23 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
/** Return display pixels without changing RGB or the source RGBA bytes. */
export function infraredAlpha(rgba, mode = 'filtered') {
const pixels = new Uint8ClampedArray(rgba);
if (mode === 'full') return pixels;
for (let i = 0; i < pixels.length; i += 4) {
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
1,
(linearMax - INFRARED_ALPHA_T0) /
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
),
);
pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t);
}
return pixels;
}
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
infraredAlpha,
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
} from './infraredAlpha.js';
test('filtered infrared preserves RGB and input bytes, scales existing alpha, and matches the old linear threshold', () => {
assert.equal(INFRARED_ALPHA_T0, 0.4);
assert.equal(INFRARED_ALPHA_T1, 0.7);
const bytes = new Uint8ClampedArray([
0, 0, 0, 255, 168, 12, 50, 255, 12, 194, 50, 200, 12, 50, 217, 128, 255,
255, 255, 0,
]);
const original = bytes.slice();
const result = infraredAlpha(bytes);
assert.deepEqual(bytes, original);
for (let i = 0; i < bytes.length; i += 4)
assert.deepEqual(result.slice(i, i + 3), bytes.slice(i, i + 3));
assert.deepEqual(
[result[3], result[7], result[11], result[15], result[19]],
[0, 0, 98, 128, 0],
);
// Cesium's old 0.55 hard cut falls between byte values 194 and 195.
assert.ok((194 / 255) ** 2.2 < 0.55);
assert.ok((195 / 255) ** 2.2 > 0.55);
const ramp = [];
for (let value = 0; value < 256; value++) {
ramp.push(infraredAlpha([value, value, value, 255])[3]);
}
assert.ok(ramp[194] < 127.5 && ramp[195] > 127.5);
assert.ok(ramp.every((value, i) => !i || value >= ramp[i - 1]));
assert.ok(Math.max(...ramp.slice(1).map((v, i) => v - ramp[i])) <= 8);
assert.ok(
Math.abs(
(0.55 - INFRARED_ALPHA_T0) / (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0) -
0.5,
) < 1e-12,
);
});
test('full infrared preserves every channel including partial and zero alpha', () => {
const pixels = new Uint8ClampedArray([
12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0,
]);
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
});
+109
View File
@@ -0,0 +1,109 @@
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { weatherImageUrl } from './source.js';
import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
/** Apply the display transfer once to a decoded image. */
export function processInfraredImage(image, mode, createCanvas) {
const canvas = createCanvas();
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
return canvas;
}
/** Decode locally, releasing the object URL on success, failure or cancellation. */
export function decodeInfraredImage(blob, signal) {
signal.throwIfAborted();
if (typeof globalThis.createImageBitmap === 'function')
return globalThis.createImageBitmap(blob);
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(blob);
const cleanup = () => {
signal.removeEventListener('abort', abort);
image.onload = image.onerror = null;
URL.revokeObjectURL(url);
};
const abort = () => {
cleanup();
image.src = '';
reject(signal.reason);
};
image.onload = () => {
cleanup();
resolve(image);
};
image.onerror = () => {
cleanup();
reject(new Error('Infrared decode failed'));
};
signal.addEventListener('abort', abort, { once: true });
image.src = url;
});
}
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
* image is always released. */
export async function acquireWeatherImage(
product,
time,
{
signal,
mode,
size,
bbox = null,
maxBytes,
createCanvas,
fetchImpl,
decodeImage = decodeInfraredImage,
now = () => performance.now(),
onFetched = () => {},
},
) {
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
signal,
});
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
const bytes = await readResponseBytesCapped(response, maxBytes);
signal.throwIfAborted();
onFetched();
const started = now();
const image = await decodeImage(
new Blob([bytes], { type: 'image/png' }),
signal,
);
try {
signal.throwIfAborted();
if (image.width !== size.width || image.height !== size.height)
throw new Error('Invalid weather image dimensions');
let texture;
if (product === 'clouds' || product === 'clouds-regional')
texture = processInfraredImage(image, mode, createCanvas);
else {
texture = createCanvas();
texture.width = image.width;
texture.height = image.height;
texture.getContext('2d').drawImage(image, 0, 0);
}
return { texture, decodeMs: now() - started };
} finally {
image.close?.();
}
}
/** Acquire one capped global mosaic before adding any imagery layer. */
export function acquireInfraredMosaic(time, options) {
return acquireWeatherImage('clouds', time, {
...options,
size: { width: 2048, height: 1024 },
maxBytes: MAX_MOSAIC_BYTES,
});
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
acquireInfraredMosaic,
decodeInfraredImage,
processInfraredImage,
MAX_MOSAIC_BYTES,
} from './infraredImage.js';
function canvas() {
const output = {};
output.getContext = () => ({
drawImage: (image) => {
output.image = image;
},
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
putImageData: (pixels) => {
output.pixels = pixels.data;
},
});
return output;
}
test('decoded pixels are processed once on a same-sized canvas', () => {
const image = { width: 1, height: 1 };
const output = processInfraredImage(image, 'filtered', canvas);
assert.equal(output.width, 1);
assert.equal(output.height, 1);
assert.equal(output.image, image);
assert.deepEqual([...output.pixels], [194, 0, 0, 98]);
assert.deepEqual(
[...processInfraredImage(image, 'full', canvas).pixels],
[194, 0, 0, 200],
);
});
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
const controller = new AbortController();
let closes = 0,
fetches = 0,
clock = 10,
fetched = false;
const options = {
signal: controller.signal,
mode: 'filtered',
createCanvas: canvas,
now: () => clock,
onFetched: () => {
fetched = true;
},
fetchImpl: async (url, { signal }) => {
fetches++;
assert.match(url, /^\/api\/weather\/image\?product=clouds&time=/);
assert.equal(signal, controller.signal);
return new Response(new Uint8Array([1, 2, 3]));
},
decodeImage: async (blob) => {
assert.equal(fetched, true);
assert.equal(blob.size, 3);
assert.equal(blob.type, 'image/png');
clock = 25;
return {
width: 2048,
height: 1024,
close() {
closes++;
},
};
},
};
const result = await acquireInfraredMosaic(
'2026-09-15T20:00:00.000Z',
options,
);
assert.equal(fetches, 1);
assert.equal(closes, 1);
assert.equal(result.decodeMs, 15);
assert.equal(result.texture.width, 2048);
for (const response of [
new Response('', {
headers: { 'content-length': String(MAX_MOSAIC_BYTES + 1) },
}),
new Response(new Uint8Array(MAX_MOSAIC_BYTES + 1)),
]) {
await assert.rejects(
acquireInfraredMosaic('2026-09-15', {
...options,
fetchImpl: async () => response,
decodeImage: () => assert.fail('oversized image must not decode'),
}),
{ code: 'RESPONSE_TOO_LARGE' },
);
}
await assert.rejects(
acquireInfraredMosaic('2026-09-15', {
...options,
decodeImage: async () => {
controller.abort();
return {
width: 2048,
height: 1024,
close() {
closes++;
},
};
},
}),
{ name: 'AbortError' },
);
assert.equal(closes, 2);
});
test('bitmap decoding and Image fallback release resources on success, error and abort', async (t) => {
const original = Object.getOwnPropertyDescriptor(
globalThis,
'createImageBitmap',
);
t.after(() => {
if (original)
Object.defineProperty(globalThis, 'createImageBitmap', original);
else delete globalThis.createImageBitmap;
});
const bitmap = {};
globalThis.createImageBitmap = async () => bitmap;
assert.equal(
await decodeInfraredImage(new Blob(), new AbortController().signal),
bitmap,
);
globalThis.createImageBitmap = undefined;
const images = [],
revoked = [];
const originalImage = globalThis.Image;
globalThis.Image = class {
constructor() {
images.push(this);
}
};
t.after(() => {
if (originalImage) globalThis.Image = originalImage;
else delete globalThis.Image;
});
t.mock.method(URL, 'createObjectURL', () => 'blob:test');
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
for (const action of ['load', 'error', 'abort']) {
const controller = new AbortController();
const pending = decodeInfraredImage(new Blob(), controller.signal);
const image = images.at(-1);
assert.equal(image.src, 'blob:test');
if (action === 'load') {
image.onload();
assert.equal(await pending, image);
} else if (action === 'error') {
image.onerror();
await assert.rejects(pending, /decode failed/);
} else {
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(image.src, '');
}
assert.equal(image.onload, null);
assert.equal(image.onerror, null);
}
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
});
+50
View File
@@ -0,0 +1,50 @@
/** Tile a bounded decoded canvas without network requests or scene state. */
export function createRasterTileProvider({
cesium,
texture,
credit,
createCanvas,
rectangle = cesium.Rectangle.MAX_VALUE,
tilingScheme = new cesium.GeographicTilingScheme({ rectangle }),
maximumLevel = 2,
tileSize = 256,
}) {
return {
tilingScheme,
rectangle,
tileWidth: tileSize,
tileHeight: tileSize,
minimumLevel: 0,
maximumLevel,
ready: true,
tileDiscardPolicy: undefined,
credit,
errorEvent: new cesium.Event(),
hasAlphaChannel: true,
getTileCredits: () => undefined,
pickFeatures: () => undefined,
requestImage(x, y, level) {
const tile = createCanvas();
tile.width = tile.height = tileSize;
const ctx = tile.getContext('2d');
const width =
texture.width / tilingScheme.getNumberOfXTilesAtLevel(level);
const height =
texture.height / tilingScheme.getNumberOfYTilesAtLevel(level);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(
texture,
x * width,
y * height,
width,
height,
0,
0,
tileSize,
tileSize,
);
// ImageryLayer consumes requestImage results as promises.
return Promise.resolve(tile);
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createRasterTileProvider } from './rasterTiles.js';
import { createFieldRaster } from '../wind/fields.js';
// A small canvas double exposes the uploaded raster and exact drawImage crop.
// Verify crop coordinates and source-cell selection independently of canvas smoothing.
function createCanvas() {
const canvas = { width: 0, height: 0 };
const context = {
createImageData: (width, height) => ({
data: new Uint8ClampedArray(width * height * 4),
}),
putImageData(pixels) {
canvas.rgba = pixels.data;
},
drawImage(source, ...args) {
canvas.source = source;
canvas.crop = args;
},
getImageData(x, y) {
const [sx, sy, sw, sh, dx, dy, dw, dh] = canvas.crop;
const col = Math.floor(sx + ((x - dx + 0.5) * sw) / dw);
const row = Math.floor(sy + ((y - dy + 0.5) * sh) / dh);
const offset = (row * canvas.source.width + col) * 4;
return { data: canvas.source.rgba.slice(offset, offset + 4) };
},
};
canvas.getContext = () => context;
return canvas;
}
test('real Cesium geographic provider crops level 0/1 tiles from a decoded canvas with preserved pixels', async () => {
const raster = createFieldRaster(
{
nx: 8,
ny: 3,
lo1: -180,
la1: 90,
dx: 45,
dy: 90,
u: Float32Array.from([
0, 0, 10, 10, 20, 20, 30, 30, 0, 0, 10, 10, 20, 20, 30, 30, 30, 30, 20,
20, 10, 10, 0, 0,
]),
v: new Float32Array(24),
},
'speed',
720,
362,
);
const credit = new Cesium.Credit('NOAA GFS');
const texture = {
width: raster.width,
height: raster.height,
rgba: raster.rgba,
};
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
credit,
createCanvas,
});
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
assert.equal(provider.rectangle, Cesium.Rectangle.MAX_VALUE);
assert.equal(provider.minimumLevel, 0);
assert.equal(provider.maximumLevel, 2);
assert.equal(provider.tileWidth, 256);
assert.equal(provider.tileHeight, 256);
assert.equal(provider.ready, true);
assert.equal(provider.hasAlphaChannel, true);
assert.equal(provider.credit, credit);
assert.ok(provider.errorEvent instanceof Cesium.Event);
assert.equal(provider.tileDiscardPolicy, undefined);
assert.equal(provider.getTileCredits(0, 0, 0), undefined);
assert.equal(provider.pickFeatures(0, 0, 0, 0, 0), undefined);
for (const [x, y, level, crop, cell] of [
[0, 0, 0, [0, 0, 360, 362, 0, 0, 256, 256], [90, 91]],
[1, 0, 0, [360, 0, 360, 362, 0, 0, 256, 256], [450, 91]],
[0, 0, 1, [0, 0, 180, 181, 0, 0, 256, 256], [45, 45]],
[3, 1, 1, [540, 181, 180, 181, 0, 0, 256, 256], [585, 226]],
]) {
const pending = provider.requestImage(x, y, level);
assert.ok(pending instanceof Promise, 'ImageryLayer consumes a promise');
const tile = await pending;
assert.equal(tile.width, 256);
assert.equal(tile.height, 256);
assert.equal(tile.getContext('2d').imageSmoothingEnabled, true);
assert.deepEqual(tile.crop, crop);
const offset = (cell[1] * 720 + cell[0]) * 4;
assert.deepEqual(
tile.getContext('2d').getImageData(64, 64).data,
raster.rgba.slice(offset, offset + 4),
);
}
});
test('bounded decoded mosaic crops both roots and higher levels without resampling its extent', async () => {
const texture = { width: 2048, height: 1024 };
const rectangle = Cesium.Rectangle.fromDegrees(-180, -60, 180, 60);
const scheme = new Cesium.GeographicTilingScheme({
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
});
const credit = new Cesium.Credit('NOAA');
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
rectangle,
tilingScheme: scheme,
maximumLevel: 3,
credit,
createCanvas,
});
assert.equal(provider.rectangle, rectangle);
assert.equal(provider.maximumLevel, 3);
assert.equal(provider.credit, credit);
assert.equal(provider.hasAlphaChannel, true);
for (const [x, y, level, crop] of [
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 256, 256]],
[1, 0, 0, [1024, 0, 1024, 1024, 0, 0, 256, 256]],
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 256, 256]],
]) {
const pending = provider.requestImage(x, y, level);
assert.ok(pending instanceof Promise);
const tile = await pending;
assert.equal(tile.source, texture);
assert.deepEqual(tile.crop, crop);
}
});
test('512-pixel crops preserve source extent at root and clamped draping levels', async () => {
const texture = { width: 2048, height: 1024 };
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
createCanvas,
tileSize: 512,
maximumLevel: 3,
});
assert.equal(provider.tileWidth, 512);
assert.equal(provider.tileHeight, 512);
for (const [x, y, level, crop] of [
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 512, 512]],
[3, 1, 1, [1536, 512, 512, 512, 0, 0, 512, 512]],
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 512, 512]],
]) {
const tile = await provider.requestImage(x, y, level);
assert.equal(tile.width, 512);
assert.equal(tile.height, 512);
assert.equal(tile.source, texture);
assert.deepEqual(tile.crop, crop);
}
});
+667
View File
@@ -0,0 +1,667 @@
import { weatherTileUrl } from './source.js';
import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js';
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import {
acquireInfraredMosaic,
processInfraredImage,
} from './infraredImage.js';
// Bounded display detail for the hourly, approximately 3 km global product.
const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
const MAX_MOSAICS = 6;
const MAX_PREFETCH_TILES = 8;
/** Globe imagery: own at most a displayed and a staging frame. Use native Cesium
* tile scheduling, projection and texture disposal; the application clock is never touched. */
function createGlobeRendering({
viewer,
cesium,
getHost,
onChange,
timeoutMs,
now,
fetchImpl,
decodeImage,
createCanvas,
}) {
let current = null;
let incoming = null;
const retiring = new Set();
let alpha = 0.7;
let frameHidden = false;
let lastError = null;
const mosaics = new Map();
let prefetchJob = null;
let prefetchedKey = null;
function profile(product) {
return {
tileSize: 256,
maximumLevel: product === 'clouds' ? GLOBAL_TILE_MAXIMUM_LEVEL : 6,
};
}
function cancelPrefetch() {
clearTimeout(prefetchJob?.timeout);
prefetchJob?.controller.abort();
prefetchJob = null;
prefetchedKey = null;
}
async function mosaic(time, mode, signal, onFetched) {
signal.throwIfAborted();
const key = `${time}|${mode}`;
const texture = mosaics.get(key);
if (texture) {
mosaics.delete(key);
mosaics.set(key, texture);
return { texture, decodeMs: 0, cached: true };
}
const result = await acquireInfraredMosaic(time, {
signal,
mode,
createCanvas,
fetchImpl,
decodeImage,
now,
onFetched,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
mosaics.delete(key);
mosaics.set(key, result.texture);
while (mosaics.size > MAX_MOSAICS)
mosaics.delete(mosaics.keys().next().value);
return { ...result, cached: false };
}
function prefetchTiles(snapshot, time) {
const { west, south, east, north } = snapshot.bounds;
const bounds = cesium.Rectangle.fromDegrees(west, south, east, north);
const view = viewer.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
const coverage = view
? cesium.Rectangle.intersection(bounds, view)
: bounds;
if (!coverage) return [];
const scheme = new cesium.GeographicTilingScheme();
const template = weatherTileUrl(snapshot.product, time, {
size: profile(snapshot.product).tileSize,
});
const urls = [];
for (let z = 0; z <= 1; z++) {
for (let y = 0; y < scheme.getNumberOfYTilesAtLevel(z); y++) {
for (let x = 0; x < scheme.getNumberOfXTilesAtLevel(z); x++) {
if (
!cesium.Rectangle.intersection(
coverage,
scheme.tileXYToRectangle(x, y, z),
)
)
continue;
urls.push(
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
);
if (urls.length === MAX_PREFETCH_TILES) return urls;
}
}
}
return urls;
}
function remove(frame) {
if (!frame) return;
frame.closed = true;
frame.controller.abort();
frame.offRetire?.();
frame.offInstall?.();
retiring.delete(frame);
frame.offError?.();
frame.offAbort?.();
frame.offRender?.();
frame.offCamera?.();
clearTimeout(frame.timeout);
for (const request of frame.requests) request.cancel?.();
frame.requests.clear();
frame.retries.clear();
if (!frame.layer) return;
const collection = frame.collection;
if (
collection &&
!collection.isDestroyed?.() &&
collection.contains(frame.layer)
)
collection.remove(frame.layer, true);
else if (!frame.layer.isDestroyed?.()) frame.layer.destroy?.();
}
function cancelIncoming() {
if (!incoming) return;
const previous = incoming;
incoming = null;
remove(previous);
previous.resolve(false);
}
function rehome() {
const host = getHost();
const { collection } = host;
const hidden = frameHidden || imageryHostStatus(host) !== null;
const changed =
(current && current.collection !== collection) ||
(incoming && incoming.collection !== collection);
const visibilityChanged = current && current.layer.show === hidden;
if (changed || imageryHostStatus(host) !== null) {
cancelPrefetch();
cancelIncoming();
for (const frame of retiring) remove(frame);
}
if (current) {
if (!hidden && current.layer.alpha !== alpha) current.layer.alpha = alpha;
if (visibilityChanged) current.layer.show = !hidden;
}
if (current && current.collection !== collection) {
current.collection?.remove(current.layer, false);
current.collection = collection;
if (collection) {
collection.add(current.layer);
orderWeatherImagery(collection, current.layer, current.priority);
}
}
if (changed || visibilityChanged) viewer.scene.requestRender();
return Boolean(changed || visibilityChanged);
}
return {
rehome,
cancelPrefetch,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job;
try {
if (
frameHidden ||
incoming ||
imageryHostStatus(getHost()) ||
!snapshot.times.includes(time)
)
return false;
const global = snapshot.product === 'clouds';
const urls = global ? [] : prefetchTiles(snapshot, time);
const key = global ? `${time}|${infrared}` : urls.join('|');
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
prefetchJob = job;
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
if (global) await mosaic(time, infrared, signal);
else
await Promise.all(
urls.map(async (url) => {
const response = await fetchImpl(url, { signal });
if (!response.ok) throw new Error('Weather prefetch unavailable');
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
}),
);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
return true;
} catch {
job?.controller.abort();
return false;
} finally {
clearTimeout(job?.timeout);
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
signal?.throwIfAborted();
cancelPrefetch();
rehome();
cancelIncoming();
const host = getHost();
const { collection, kind } = host;
if (imageryHostStatus(host)) return false;
const nextProfile = profile(snapshot.product);
if (
current?.time === time &&
current.product === snapshot.product &&
current.kind === kind &&
current.infrared === infrared
)
return true;
lastError = null;
const { west, south, east, north } = snapshot.bounds;
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
const global = snapshot.product === 'clouds';
const frame = {
snapshot,
time,
infrared,
mosaic: global
? { fetched: false, decodeMs: null, cached: false }
: undefined,
controller: new AbortController(),
collection,
kind,
priority:
snapshot.product === 'lightning'
? 3
: snapshot.product === 'radar'
? 2
: 1,
product: snapshot.product,
requests: new Set(),
retries: new Map(),
deferred: new Set(),
pending: 0,
loaded: 0,
lastActivity: now(),
startedAt: now(),
closed: false,
failed: false,
resolve: null,
};
incoming = frame;
const result = new Promise((resolve) => {
frame.resolve = resolve;
});
const finish = (ok) => {
if (incoming !== frame) return;
incoming = null;
frame.offRender?.();
frame.offRender = null;
frame.offCamera?.();
frame.offCamera = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
if (ok) {
lastError = null;
const previous = current;
current = frame;
frame.loadMs = now() - frame.startedAt;
frame.layer.alpha = alpha;
frame.layer.show = !frameHidden;
viewer.scene.requestRender();
if (previous) {
retiring.add(previous);
previous.offRetire = viewer.scene.postRender.addEventListener(
() => {
remove(previous);
viewer.scene.requestRender();
},
);
}
} else {
lastError = 'Weather tiles unavailable · previous frame retained';
remove(frame);
}
frame.resolve(ok);
viewer.scene.requestRender();
onChange();
};
const abort = () => {
if (incoming === frame) cancelIncoming();
viewer.scene.requestRender();
};
signal?.addEventListener('abort', abort, { once: true });
frame.offAbort = () => signal?.removeEventListener('abort', abort);
frame.timeout = setTimeout(() => finish(false), timeoutMs);
const install = (texture) => {
if (frame.closed) return;
// Keep at most two installed layers during rapid successive selections.
if (retiring.size) {
frame.offInstall = viewer.scene.postRender.addEventListener(() => {
frame.offInstall();
frame.offInstall = null;
try {
install(texture);
} catch {
finish(false);
}
});
viewer.scene.requestRender();
return;
}
const tilingScheme = new cesium.GeographicTilingScheme(
global
? {
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
}
: undefined,
);
const credit = new cesium.Credit(
snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
false,
);
const provider = global
? createRasterTileProvider({
cesium,
texture,
rectangle,
tilingScheme,
maximumLevel: GLOBAL_TILE_MAXIMUM_LEVEL,
tileSize: nextProfile.tileSize,
credit,
createCanvas,
})
: new cesium.UrlTemplateImageryProvider({
url: weatherTileUrl(snapshot.product, time, {
size: nextProfile.tileSize,
}),
tilingScheme,
rectangle,
tileWidth: nextProfile.tileSize,
tileHeight: nextProfile.tileSize,
maximumLevel: nextProfile.maximumLevel,
enablePickFeatures: false,
credit,
});
const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (frame.closed) return undefined;
const result = requestImage(x, y, level, request);
const tileKey = `${level}/${x}/${y}`;
if (!result) {
// Scheduler admission is part of readiness, not a successful tile.
frame.deferred.add(tileKey);
frame.lastActivity = now();
return result;
}
frame.deferred.delete(tileKey);
frame.pending++;
frame.lastActivity = now();
if (request) frame.requests.add(request);
return Promise.resolve(result)
.then((image) => {
if (!frame.closed && snapshot.product === 'clouds-regional')
image = processInfraredImage(image, infrared, createCanvas);
frame.retries.delete(tileKey);
frame.loaded++;
return image;
})
.finally(() => {
frame.pending--;
frame.lastActivity = now();
frame.requests.delete(request);
if (!frame.closed) viewer.scene.requestRender();
});
};
frame.offError = provider.errorEvent.addEventListener((error) => {
if (frame.closed) return;
const status = error?.error?.statusCode;
// Cesium retries synchronously after this event; no delay hook is exposed.
const tileKey = `${error.level}/${error.x}/${error.y}`;
const retries = frame.retries.get(tileKey) ?? 0;
error.retry = false;
if ((status === 429 || status === 503) && retries < 3) {
frame.retries.set(tileKey, retries + 1);
error.retry = true;
return;
}
frame.failed = true;
lastError = 'Some weather tiles unavailable';
onChange();
});
// A shown, transparent layer lets Cesium request staging tiles while the last
// complete observation stays visible underneath it.
frame.layer = collection.addImageryProvider(provider);
frame.layer.alpha = 0;
orderWeatherImagery(collection, frame.layer, frame.priority);
let settled = 0;
frame.offCamera = viewer.camera?.moveEnd?.addEventListener(() => {
// Tiles abandoned by a previous viewport are no longer admission work.
frame.deferred.clear();
frame.lastActivity = now();
settled = 0;
viewer.scene.requestRender();
});
frame.offRender = viewer.scene.postRender.addEventListener(() => {
if (frame.failed) return finish(false);
// Unrelated terrain/basemap work must not indefinitely hold a ready
// observation. Require successful own tiles and a quiet scheduling
// interval before admitting a frame when the rest of the globe is busy.
const ownReady =
frame.loaded > 0 &&
frame.deferred.size === 0 &&
now() - frame.lastActivity >= 200;
if (
((frame.kind === 'globe' && viewer.scene.globe.tilesLoaded) ||
ownReady) &&
frame.pending === 0
) {
if (++settled >= 2) finish(true);
else viewer.scene.requestRender();
} else settled = 0;
if (incoming === frame && frame.pending === 0 && frame.loaded > 0)
viewer.scene.requestRender();
});
viewer.scene.requestRender();
onChange();
};
if (global) {
void mosaic(time, infrared, frame.controller.signal, () => {
frame.mosaic.fetched = true;
})
.then(({ texture, decodeMs, cached }) => {
frame.mosaic.decodeMs = decodeMs;
frame.mosaic.cached = cached;
install(texture);
})
.catch(() => finish(false));
} else {
try {
install();
} catch {
finish(false);
}
}
viewer.scene.requestRender();
onChange();
return result;
},
setHidden(value) {
frameHidden = Boolean(value);
if (frameHidden) {
cancelPrefetch();
cancelIncoming();
for (const frame of retiring) remove(frame);
}
rehome();
viewer.scene.requestRender();
},
setAlpha(value) {
alpha = value;
if (current && current.layer.show) current.layer.alpha = alpha;
viewer.scene.requestRender();
},
clear() {
cancelPrefetch();
cancelIncoming();
remove(current);
for (const frame of retiring) remove(frame);
current = null;
frameHidden = false;
lastError = null;
mosaics.clear();
viewer.scene.requestRender();
},
getDiagnostics() {
return {
host: 'globe',
cache: { mosaics: mosaics.size, prefetching: !!prefetchJob },
imageryCount:
Number(!!current) + Number(!!incoming?.layer) + retiring.size,
mosaic: (incoming || current)?.mosaic,
infrared: (incoming || current)?.infrared ?? 'filtered',
loading: !!incoming,
time: frameHidden ? null : (current?.time ?? null),
hidden: frameHidden,
product: current?.product ?? null,
pendingTiles: incoming?.pending ?? 0,
deferredTiles: incoming?.deferred.size ?? 0,
loadedTiles: (incoming || current)?.loaded ?? 0,
frameLoadMs: current?.loadMs ?? null,
error: imageryHostStatus(getHost()) || lastError,
};
},
};
}
/** Globe hosts keep draped imagery; 3D Tiles hosts get a raised shell per product.
* A host switch tears one renderer down and restages the last frame on the other. */
export function createWeatherRendering({
viewer,
cesium,
getHost = () => ({ collection: viewer.imageryLayers, kind: 'globe' }),
onChange = () => {},
timeoutMs = 25_000,
now = () => performance.now(),
fetchImpl = (...args) => globalThis.fetch(...args),
decodeImage,
createCanvas = () => document.createElement('canvas'),
createShell = createWeatherShell,
}) {
const shared = {
viewer,
cesium,
getHost,
onChange,
timeoutMs,
now,
fetchImpl,
decodeImage,
createCanvas,
};
const globe = createGlobeRendering(shared);
let shell = null;
let active = null;
let alpha = 0.7;
let hidden = false;
let shown = null;
let restaging = null;
// A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => {
const { kind } = getHost();
return kind === 'tileset'
? 'shell'
: kind === 'globe'
? 'globe'
: (active ?? 'globe');
};
const renderer = () => (active === 'shell' ? shell : globe);
function shellFor(product) {
if (shell?.product === product) return shell;
shell?.clear();
shell = createShell({ ...shared, product });
shell.setAlpha(alpha);
shell.setHidden(hidden);
return shell;
}
function restage(frame) {
const target =
active === 'shell' ? shellFor(frame.snapshot.product) : globe;
restaging = frame;
const settle = (ok) => {
if (restaging !== frame) return;
restaging = null;
if (!ok) onChange();
};
target
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
.then(settle, () => settle(false));
}
function switchHost() {
const next = wanted();
if (next === active) return false;
const previous = active;
active = next;
if (previous === null) return false;
restaging = null;
if (previous === 'shell') {
shell?.clear();
shell = null;
} else globe.clear();
if (active === 'globe') {
globe.setAlpha(alpha);
if (hidden) globe.setHidden(true);
}
if (shown && !hidden) restage(shown);
viewer.scene.requestRender();
return true;
}
return {
rehome() {
const switched = switchHost();
if (active === 'shell' && !shell) return switched;
return renderer().rehome() || switched;
},
cancelPrefetch() {
renderer()?.cancelPrefetch();
},
prefetch(snapshot, time, options) {
switchHost();
if (active === 'shell' && shell?.product !== snapshot.product)
return Promise.resolve(false);
return renderer().prefetch(snapshot, time, options);
},
async setFrame(snapshot, time, options = {}) {
options.signal?.throwIfAborted();
switchHost();
restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const ok = await target.setFrame(snapshot, time, options);
if (ok)
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
return ok;
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) restaging = null;
renderer()?.setHidden(hidden);
},
setAlpha(value) {
alpha = value;
renderer()?.setAlpha(value);
},
clear() {
restaging = null;
shown = null;
hidden = false;
shell?.clear();
shell = null;
globe.clear();
},
getDiagnostics() {
const diagnostics =
active === 'shell' && shell
? shell.getDiagnostics()
: active === 'shell'
? {
host: 'shell',
cache: { mosaics: 0, bytes: 0, prefetching: false },
imageryCount: 0,
infrared: 'filtered',
loading: false,
time: null,
hidden,
product: null,
frameLoadMs: null,
error: imageryHostStatus(getHost()),
}
: globe.getDiagnostics();
// A host switch restages the retained frame; keep reporting its time.
return restaging && !hidden && diagnostics.time === null
? { ...diagnostics, time: restaging.time }
: diagnostics;
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import * as Cesium from 'cesium';
/** Test double for the Cesium classes a weather shell constructs. */
export function createShellCesium({ maximumTextureSize = 0 } = {}) {
const templates = new Map();
const created = { materials: [], primitives: [], appearances: [] };
class Material {
constructor(options) {
const template = templates.get(options.fabric.type);
this.options = options;
this.uniforms = {
...template.fabric.uniforms,
imageDimensions: { type: 'ivec3', x: 1, y: 1 },
detailDimensions: { type: 'ivec3', x: 1, y: 1 },
};
this.destroyed = false;
created.materials.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
Material.DefaultImageId = Cesium.Material.DefaultImageId;
Material._materialCache = {
getMaterial: (type) => templates.get(type),
addMaterial: (type, template) => templates.set(type, template),
};
class Primitive {
constructor(options) {
this.options = options;
this.appearance = options.appearance;
this.show = options.show ?? true;
this.ready = false;
this.destroyed = false;
created.primitives.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
class EllipsoidSurfaceAppearance {
constructor(options) {
this.options = options;
this.material = options.material;
created.appearances.push(this);
}
}
EllipsoidSurfaceAppearance.VERTEX_FORMAT =
Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT;
class RectangleGeometry {
constructor(options) {
this.options = options;
}
}
class GeometryInstance {
constructor(options) {
this.geometry = options.geometry;
}
}
return {
Material,
Primitive,
EllipsoidSurfaceAppearance,
RectangleGeometry,
GeometryInstance,
BlendingState: Cesium.BlendingState,
Cartesian4: Cesium.Cartesian4,
Math: Cesium.Math,
Rectangle: Cesium.Rectangle,
ContextLimits: { maximumTextureSize },
created,
templates,
};
}
/** A scene with an ordered primitive collection that destroys on remove. */
export function createShellScene() {
const items = [];
const listeners = new Set();
let renders = 0;
return {
primitives: {
items,
add(primitive) {
items.push(primitive);
return primitive;
},
remove(primitive) {
const index = items.indexOf(primitive);
if (index < 0) return false;
items.splice(index, 1);
primitive.destroy();
return true;
},
contains: (primitive) => items.includes(primitive),
get: (index) => items[index],
get length() {
return items.length;
},
lowerToBottom(primitive) {
const index = items.indexOf(primitive);
if (index >= 0) items.unshift(...items.splice(index, 1));
},
isDestroyed: () => false,
},
postRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
emit() {
for (const listener of [...listeners]) listener();
},
get size() {
return listeners.size;
},
},
requestRender() {
renders++;
},
get renders() {
return renders;
},
};
}
/** Render: finish asynchronous geometry, upload each shown material's images.
* An image uniform back at the default binds Cesium's 1×1 texture. */
export function renderShells(cesium, scene, count = 4) {
for (let i = 0; i < count; i++) {
for (const primitive of cesium.created.primitives) {
if (primitive.destroyed || !primitive.show) continue;
primitive.ready = true;
const { uniforms } = primitive.appearance.material;
for (const name of ['image', 'detail']) {
const dimensions = uniforms[`${name}Dimensions`];
if (!dimensions) continue;
const bound =
typeof uniforms[name] === 'object'
? uniforms[name]
: { width: 1, height: 1 };
dimensions.x = bound.width;
dimensions.y = bound.height;
}
}
scene.postRender.emit();
}
}
+867
View File
@@ -0,0 +1,867 @@
import { acquireWeatherImage } from './infraredImage.js';
import { imageryHostStatus } from './imageryHost.js';
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
wind: 5_000,
clouds: 5_500,
'clouds-regional': 5_800,
radar: 6_200,
lightning: 6_600,
});
// Decoded canvases per renderer, full-extent and detail images alike: the shown
// frame and the warmed next frame, each full-extent and detail (four 4096×2048
// canvases); nothing older survives them. Cesium holds the shown canvases
// through the material uniforms in any case.
export const WEATHER_SHELL_CACHE_BYTES = 128 * 1024 * 1024;
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
const MATERIAL_TYPE = 'WeatherFrame';
// Renders after an image swap: one queues the upload, one uploads and draws, one spare.
const UPLOAD_FRAMES = 3;
// An empty detail window: the full-extent image shows everywhere.
const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
// Detail windows, in degrees.
const DETAIL_MIN_WIDTH = 6;
const DETAIL_GRID = 0.5;
const shellHeights = new WeakMap();
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
// draw nothing until the full-extent image has arrived, and sample the detail
// image only once it has arrived. The window is west, south, east, north in the
// full-extent image's texture coordinates.
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
vec4 coarse = texture(image, st);
vec2 dst = (st - window.xy) / max(window.zw - window.xy, vec2(1e-6));
float inside = float(all(greaterThanEqual(st, window.xy)) && all(lessThanEqual(st, window.zw)));
float useDetail = inside * step(1.5, float(detailDimensions.x));
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
material.diffuse = czm_gammaCorrect(c.rgb);
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
return material;
}
`;
function registerMaterial(cesium) {
const cache = cesium.Material._materialCache;
if (cache.getMaterial(MATERIAL_TYPE)) return;
// A plain template: building the first material from a fabric would make that
// instance Cesium's shared template and keep its last image alive.
cache.addMaterial(MATERIAL_TYPE, {
fabric: {
type: MATERIAL_TYPE,
uniforms: {
image: cesium.Material.DefaultImageId,
detail: cesium.Material.DefaultImageId,
alpha: 1,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
},
// Diffuse and alpha share these samples, so one source, not components.
source: MATERIAL_SOURCE,
},
translucent: false,
});
}
const sameEdges = (a, b) =>
a.west === b.west &&
a.south === b.south &&
a.east === b.east &&
a.north === b.north;
/** Keep weather shells first in the primitive collection, in ascending height:
* higher shells draw over lower ones and other opaque content draws over both. */
export function orderWeatherShells(primitives, primitive, height) {
shellHeights.set(primitive, height);
if (typeof primitives.lowerToBottom !== 'function') return;
const shells = [];
for (let i = 0; i < primitives.length; i++) {
const item = primitives.get(i);
if (shellHeights.has(item)) shells.push(item);
}
const ordered = [...shells].sort(
(a, b) => shellHeights.get(a) - shellHeights.get(b),
);
if (ordered.every((item, i) => primitives.get(i) === item)) return;
for (const item of ordered.reverse()) primitives.lowerToBottom(item);
}
/** One raised rectangle drawing one full-extent image and, inside a window of
* it, an optional detail image. The owner calls destroy(). */
export function createShellSurface({
viewer,
cesium,
rectangle,
height,
onSettled = () => {},
}) {
registerMaterial(cesium);
const scene = viewer.scene;
const primitives = scene.primitives;
const material = new cesium.Material({
fabric: { type: MATERIAL_TYPE },
translucent: false,
});
// Flat shading keeps the product colours independent of the viewing angle.
const appearance = new cesium.EllipsoidSurfaceAppearance({
aboveGround: true,
flat: true,
translucent: false,
material,
});
// Cesium orders its translucent pass itself (weighted OIT or distance), which
// cannot honour stacked heights. Draw in the opaque pass in scene.primitives
// order, alpha-blended and without depth writes, so higher shells draw over
// lower ones and nothing is hidden or picked through a shell.
appearance.getRenderState = () => ({
depthTest: { enabled: true },
depthMask: false,
blending: cesium.BlendingState.ALPHA_BLEND,
});
const primitive = new cesium.Primitive({
geometryInstances: new cesium.GeometryInstance({
geometry: new cesium.RectangleGeometry({
rectangle,
height,
granularity: cesium.Math.toRadians(0.5),
vertexFormat: cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
}),
}),
appearance,
asynchronous: true,
allowPicking: false,
});
primitives.add(primitive);
orderWeatherShells(primitives, primitive, height);
let image = null;
let frames = 0;
// The detail image, its own upload count and a window waiting for it to draw.
let detail = null;
let detailFrames = 0;
let pendingWindow = null;
let offRender = null;
let destroyed = false;
const fits = (size, value) =>
!size || (size.x === value.width && size.y === value.height);
const uploaded = () =>
image !== null && fits(material.uniforms.imageDimensions, image);
const detailDrawn = () =>
detail !== null &&
detailFrames === 0 &&
fits(material.uniforms.detailDimensions, detail);
const drawn = () =>
primitive.ready &&
uploaded() &&
frames === 0 &&
(detail === null || (detailDrawn() && pendingWindow === null));
function appliedWindow() {
const { x, y, z, w } = material.uniforms.window;
return z < x ? null : { west: x, south: y, east: z, north: w };
}
function setWindow(rect) {
const next = rect ?? NO_WINDOW;
const { x, y, z, w } = material.uniforms.window;
if (
x === next.west &&
y === next.south &&
z === next.east &&
w === next.north
)
return;
material.uniforms.window = new cesium.Cartesian4(
next.west,
next.south,
next.east,
next.north,
);
scene.requestRender();
}
// The render governor may idle the scene; request frames only until the
// asynchronous geometry is ready and the latest images have been drawn.
function tick() {
// Cesium updates a material only while its primitive is shown, so hidden
// frames upload nothing and count for nothing.
if (primitive.ready && primitive.show) {
if (frames > 0) frames--;
if (detailFrames > 0) detailFrames--;
// A texture is never shown at another window's place.
if (pendingWindow && detailDrawn()) {
setWindow(pendingWindow);
pendingWindow = null;
}
}
if (primitive.show && !drawn()) {
scene.requestRender();
return;
}
offRender?.();
offRender = null;
if (drawn()) onSettled();
}
function wake() {
if (destroyed || !primitive.show || image === null || drawn()) return;
offRender ??= scene.postRender.addEventListener(tick);
scene.requestRender();
}
return {
setImage(next) {
if (destroyed || next === image) return;
image = next;
material.uniforms.image = next;
frames = UPLOAD_FRAMES;
wake();
},
setAlpha(value) {
if (destroyed || material.uniforms.alpha === value) return;
material.uniforms.alpha = value;
scene.requestRender();
},
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
* image's texture coordinates, 0–1); null clears it. In the same window only
* the image changes, and Cesium keeps drawing the previous texture until
* this one is uploaded. A different window applies once its image has
* drawn; until then the full-extent image covers it. */
setDetail(next, rect) {
if (destroyed) return;
if (!next) {
if (detail === null) return;
detail = null;
detailFrames = 0;
pendingWindow = null;
material.uniforms.detail = cesium.Material.DefaultImageId;
setWindow(null);
scene.requestRender();
return;
}
if (next !== detail) {
detail = next;
material.uniforms.detail = next;
detailFrames = UPLOAD_FRAMES;
}
const applied = appliedWindow();
if (applied && sameEdges(applied, rect)) {
pendingWindow = null;
} else {
setWindow(null);
pendingWindow = rect;
}
wake();
},
/** Returns whether visibility changed. */
setShow(value) {
const show = Boolean(value);
if (destroyed || primitive.show === show) return false;
primitive.show = show;
wake();
scene.requestRender();
return true;
},
destroy() {
if (destroyed) return;
destroyed = true;
offRender?.();
offRender = null;
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
primitives.remove(primitive);
if (!primitive.isDestroyed()) primitive.destroy();
// Primitive.destroy leaves the material and its texture alive.
if (!material.isDestroyed()) material.destroy();
image = null;
detail = null;
pendingWindow = null;
scene.requestRender();
},
getDiagnostics() {
return {
height,
ready: Boolean(primitive.ready),
uploaded: uploaded(),
show: primitive.show,
alpha: material.uniforms.alpha,
rendering: offRender !== null,
// `uploaded` once the detail image has drawn; the window applies after.
detail: { uploaded: detailDrawn(), window: appliedWindow() },
};
},
};
}
function fitTexture(cesium, { width, height }) {
const limit = cesium.ContextLimits?.maximumTextureSize;
// Halve, down to 1024 px wide, on devices with a smaller texture limit.
while (limit > 0 && width > limit && width > 1024) {
width /= 2;
height /= 2;
}
return { width, height };
}
/** The detail window for a view footprint over product bounds (both in degrees;
* a footprint across the antimeridian has west > east), or null when the
* full-extent image is already the best available. The window is centred on
* the footprint, max(2 × its longitude span, 6°) wide and half as tall, with
* edges on a 0.5° grid inside the bounds. It is enabled only when narrower than
* half the product's longitude extent and when the footprint overlaps the
* product. `previous` is returned unchanged while the footprint centre stays in
* its inner half and the wanted width is within 50 % of its width. */
export function detailWindow(footprint, bounds, previous = null) {
if (!footprint || !bounds) return null;
const across = footprint.east < footprint.west;
const span = footprint.east - footprint.west + (across ? 360 : 0);
let lon = footprint.west + span / 2;
if (lon > 180) lon -= 360;
const lat = (footprint.south + footprint.north) / 2;
const wanted = Math.max(2 * span, DETAIL_MIN_WIDTH);
if (previous) {
const width = previous.east - previous.west;
const height = previous.north - previous.south;
if (
Math.abs(lon - (previous.west + previous.east) / 2) <= width / 4 &&
Math.abs(lat - (previous.south + previous.north) / 2) <= height / 4 &&
Math.abs(wanted - width) <= width / 2
)
return previous;
}
const inner = {
west: Math.ceil(bounds.west / DETAIL_GRID) * DETAIL_GRID,
south: Math.ceil(bounds.south / DETAIL_GRID) * DETAIL_GRID,
east: Math.floor(bounds.east / DETAIL_GRID) * DETAIL_GRID,
north: Math.floor(bounds.north / DETAIL_GRID) * DETAIL_GRID,
};
// Whole degrees keep both the edges and the 2:1 height on the grid; the
// tolerance absorbs radian round trips.
const width = Math.ceil(wanted - 1e-9);
const height = width / 2;
const overlaps =
footprint.south < bounds.north &&
footprint.north > bounds.south &&
(across
? footprint.west < bounds.east || footprint.east > bounds.west
: footprint.west < bounds.east && footprint.east > bounds.west);
if (
!overlaps ||
width >= (bounds.east - bounds.west) / 2 ||
width > inner.east - inner.west ||
height > inner.north - inner.south
)
return null;
const snap = (value) => Math.round(value / DETAIL_GRID) * DETAIL_GRID;
const west = Math.min(
Math.max(snap(lon - width / 2), inner.west),
inner.east - width,
);
const south = Math.min(
Math.max(snap(lat - height / 2), inner.south),
inner.north - height,
);
const next = { west, south, east: west + width, north: south + height };
return previous && sameEdges(previous, next) ? previous : next;
}
/** Observed weather on 3D Tiles: one raised shell per product and one
* full-extent image per frame. The previous image stays until the next is ready.
* Inside a window around the view the same surface samples a sharper image of
* that area, so the two images share one mesh and never blend. */
export function createWeatherShell({
viewer,
cesium,
product,
height = WEATHER_SHELL_HEIGHTS[product],
getHost = () => ({ collection: null, kind: 'tileset' }),
onChange = () => {},
timeoutMs = 25_000,
now = () => performance.now(),
fetchImpl = (...args) => globalThis.fetch(...args),
decodeImage,
createCanvas = () => document.createElement('canvas'),
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
}) {
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
throw new TypeError('Unknown weather product');
const scene = viewer.scene;
const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]);
const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE);
const infrared = product === 'clouds' || product === 'clouds-regional';
// Global infrared contrast depends on the requested extent, so a window would
// not match the image around it; the globe host also shows one mosaic.
const detailed = product !== 'clouds';
let surface = null;
let current = null;
let incoming = null;
let alpha = 0.7;
let hidden = false;
let lastError = null;
const images = new Map();
let imageBytes = 0;
let prefetchJob = null;
let prefetchedKey = null;
// The detail window for the shown bounds, the image drawn in it and its fetch.
let view = null;
let viewBounds = null;
let detail = null;
let detailJob = null;
let offCamera = null;
const suspended = () => imageryHostStatus(getHost()) !== null;
const visible = () => !hidden && !suspended();
const boundsKey = ({ west, south, east, north }) =>
`${west},${south},${east},${north}`;
const cacheKey = (time, mode, box = null) =>
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
const wantedDetail = () =>
current && view ? cacheKey(current.time, current.infrared, view) : null;
function evict(keep = []) {
const kept = new Set([...keep, current?.key, detail?.key]);
for (const [key, entry] of images) {
if (imageBytes <= cacheBytes) return;
if (kept.has(key)) continue;
images.delete(key);
imageBytes -= entry.bytes;
}
}
async function acquire(
time,
mode,
signal,
{ box = null, keep = [], onFetched } = {},
) {
signal.throwIfAborted();
const key = cacheKey(time, mode, box);
const entry = images.get(key);
if (entry) {
images.delete(key);
images.set(key, entry);
return { texture: entry.image, decodeMs: 0, cached: true };
}
const result = await acquireWeatherImage(product, time, {
signal,
mode,
size: box ? detailSize : size,
bbox: box,
maxBytes: MAX_IMAGE_BYTES,
createCanvas,
fetchImpl,
decodeImage,
now,
onFetched,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
const previous = images.get(key);
if (previous) imageBytes -= previous.bytes;
images.delete(key);
const bytes = result.texture.width * result.texture.height * 4;
images.set(key, { image: result.texture, bytes });
imageBytes += bytes;
evict([key, ...keep]);
return { ...result, cached: false };
}
/** The detail box in the full-extent image's texture coordinates. */
function detailRect() {
const b = current.extent;
const w = detail.box;
const x = b.east - b.west;
const y = b.north - b.south;
return {
west: (w.west - b.west) / x,
south: (w.south - b.south) / y,
east: (w.east - b.west) / x,
north: (w.north - b.south) / y,
};
}
function syncDetail() {
if (detail && current) surface?.setDetail(detail.image, detailRect());
else surface?.setDetail(null);
}
function applyVisibility() {
return surface?.setShow(visible()) ?? false;
}
function cancelDetail() {
clearTimeout(detailJob?.timeout);
detailJob?.controller.abort();
detailJob = null;
}
function dropDetail() {
detail = null;
// The full-extent image covers the window in the same turn.
surface?.setDetail(null);
}
function installDetail(job, image) {
if (detailJob !== job) return;
clearTimeout(job.timeout);
detailJob = null;
detail = { box: job.box, key: job.key, image, stale: false };
syncDetail();
evict();
scene.requestRender();
}
function failDetail(job) {
if (detailJob !== job) return;
clearTimeout(job.timeout);
detailJob = null;
if (detail?.stale) dropDetail();
scene.requestRender();
}
/** Bring the detail to the shown frame and window. The previous detail image
* stays until the next is decoded unless the window itself moved, and over at
* most one newer frame (see commit). */
function refreshDetail() {
const key = wantedDetail();
if (!key) {
cancelDetail();
dropDetail();
return;
}
if (detail && boundsKey(detail.box) !== boundsKey(view)) dropDetail();
syncDetail();
if (detail?.key === key) {
detail.stale = false;
cancelDetail();
return;
}
// Another frame's detail: the next commit drops it unless replaced first.
if (detail) detail.stale = true;
if (!visible()) {
cancelDetail();
return;
}
if (detailJob?.key === key) return;
cancelDetail();
const job = { key, box: view, controller: new AbortController() };
detailJob = job;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
void acquire(current.time, current.infrared, job.controller.signal, {
box: view,
})
.then(({ texture }) => installDetail(job, texture))
.catch(() => failDetail(job));
}
/** Recompute the window from the camera's ground footprint; returns whether it changed. */
function computeView() {
const rectangle = detailed
? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid)
: null;
const degrees = cesium.Math.toDegrees;
const footprint = rectangle && {
west: degrees(rectangle.west),
south: degrees(rectangle.south),
east: degrees(rectangle.east),
north: degrees(rectangle.north),
};
const next = detailWindow(
footprint,
current.extent,
viewBounds === current.bounds ? view : null,
);
viewBounds = current.bounds;
if (next === view) return false;
view = next;
return true;
}
function watchCamera() {
if (offCamera || !detailed) return;
offCamera =
viewer.camera?.moveEnd?.addEventListener(() => {
if (current && computeView()) refreshDetail();
}) ?? null;
}
function cancelPrefetch() {
clearTimeout(prefetchJob?.timeout);
prefetchJob?.controller.abort();
prefetchJob = null;
prefetchedKey = null;
}
function watch(frame, signal) {
if (!signal) return;
frame.owned = true;
const abort = () => {
if (incoming === frame) cancelIncoming();
scene.requestRender();
};
signal.addEventListener('abort', abort, { once: true });
frame.offAbort = () => signal.removeEventListener('abort', abort);
}
function close(frame) {
frame.closed = true;
frame.controller.abort();
clearTimeout(frame.timeout);
frame.offAbort?.();
frame.surface?.destroy();
frame.surface = null;
}
function cancelIncoming() {
if (!incoming) return;
const frame = incoming;
incoming = null;
close(frame);
frame.resolve(false);
}
function fail(frame) {
if (incoming !== frame) return;
incoming = null;
close(frame);
lastError = 'Weather tiles unavailable · previous frame retained';
frame.resolve(false);
scene.requestRender();
onChange();
}
function commit(frame) {
if (incoming !== frame) return;
incoming = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
if (frame.surface) {
surface?.destroy();
surface = frame.surface;
frame.surface = null;
}
const { west, south, east, north } = frame.snapshot.bounds;
current = {
time: frame.time,
product: frame.product,
infrared: frame.infrared,
bounds: frame.bounds,
extent: { west, south, east, north },
key: frame.key,
mosaic: frame.mosaic,
loadMs: now() - frame.startedAt,
};
surface.setAlpha(alpha);
applyVisibility();
lastError = null;
evict();
// The full-extent image swaps first; the detail follows for the same time.
watchCamera();
if (viewBounds !== current.bounds) computeView();
// An older frame's detail stays over at most one newer frame.
if (detail?.stale && detail.key !== wantedDetail()) dropDetail();
refreshDetail();
frame.resolve(true);
scene.requestRender();
onChange();
}
function install(frame, image) {
if (frame.closed || incoming !== frame) return;
if (surface?.bounds === frame.bounds) {
// Cesium keeps drawing the previous texture until this one is uploaded.
surface.setImage(image);
commit(frame);
return;
}
// First frame or a new extent: stage an invisible surface and keep the
// previous one until the new geometry and texture are drawable.
const { west, south, east, north } = frame.snapshot.bounds;
const next = createShellSurface({
viewer,
cesium,
height,
rectangle: cesium.Rectangle.fromDegrees(west, south, east, north),
onSettled: () => commit(frame),
});
next.bounds = frame.bounds;
frame.surface = next;
next.setAlpha(0);
next.setImage(image);
}
return {
product,
rehome() {
if (suspended()) {
cancelPrefetch();
cancelIncoming();
cancelDetail();
}
const changed = applyVisibility();
if (current && !suspended()) {
computeView();
refreshDetail();
}
if (changed) scene.requestRender();
return changed;
},
cancelPrefetch,
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
let job;
try {
if (
hidden ||
incoming ||
suspended() ||
snapshot.product !== product ||
!snapshot.times.includes(time)
)
return false;
// Warm the next frame's detail window too while the window stays put.
const box =
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
const coarse = cacheKey(time, mode);
const key = box ? cacheKey(time, mode, box) : coarse;
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
prefetchJob = job;
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
// Both images at once: one after the other would outlast playback's step.
await Promise.all([
acquire(time, mode, signal, { keep: box ? [key] : [] }),
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
]);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
return true;
} catch {
job?.controller.abort();
return false;
} finally {
clearTimeout(job?.timeout);
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(
snapshot,
time,
{ signal, infrared: mode = 'filtered' } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
const bounds = boundsKey(snapshot.bounds);
const same = (frame) =>
frame?.time === time &&
frame.product === snapshot.product &&
frame.infrared === mode &&
frame.bounds === bounds;
// A request for the frame a host switch is restaging joins that work.
if (same(incoming) && !incoming.owned && !suspended()) {
watch(incoming, signal);
return incoming.result;
}
cancelIncoming();
applyVisibility();
if (suspended()) return false;
if (same(current)) return true;
lastError = null;
const frame = {
snapshot,
time,
infrared: mode,
bounds,
product: snapshot.product,
key: cacheKey(time, mode),
mosaic: { fetched: false, decodeMs: null, cached: false },
controller: new AbortController(),
startedAt: now(),
surface: null,
owned: false,
closed: false,
resolve: null,
};
incoming = frame;
frame.result = new Promise((resolve) => {
frame.resolve = resolve;
});
watch(frame, signal);
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
void acquire(time, mode, frame.controller.signal, {
onFetched: () => {
frame.mosaic.fetched = true;
},
})
.then(({ texture, decodeMs, cached }) => {
frame.mosaic.decodeMs = decodeMs;
frame.mosaic.cached = cached;
install(frame, texture);
})
.catch(() => fail(frame));
scene.requestRender();
onChange();
return frame.result;
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) {
cancelPrefetch();
cancelIncoming();
cancelDetail();
}
applyVisibility();
if (!hidden) refreshDetail();
scene.requestRender();
},
setAlpha(value) {
alpha = value;
surface?.setAlpha(alpha);
scene.requestRender();
},
clear() {
cancelPrefetch();
cancelIncoming();
cancelDetail();
offCamera?.();
offCamera = null;
dropDetail();
surface?.destroy();
surface = null;
current = null;
view = null;
viewBounds = null;
hidden = false;
lastError = null;
images.clear();
imageBytes = 0;
scene.requestRender();
},
getDiagnostics() {
const drawn = surface?.getDiagnostics();
const applied = drawn?.detail.window;
return {
host: 'shell',
height,
imageSize: { ...size },
shell: drawn
? {
...drawn,
detail: {
bbox: view
? [view.west, view.south, view.east, view.north]
: null,
size: { ...detailSize },
// The wanted window is applied and its image drawn.
ready: Boolean(
detail &&
current &&
detail.key === wantedDetail() &&
drawn.ready &&
drawn.show &&
drawn.detail.uploaded &&
applied &&
sameEdges(applied, detailRect()),
),
enabled: view !== null,
},
}
: null,
cache: {
mosaics: images.size,
bytes: imageBytes,
prefetching: !!prefetchJob,
},
imageryCount: Number(!!surface) + Number(!!incoming?.surface),
mosaic: (incoming || current)?.mosaic,
infrared: (incoming || current)?.infrared ?? 'filtered',
loading: !!incoming,
time: hidden ? null : (current?.time ?? null),
hidden,
product: current?.product ?? null,
frameLoadMs: current?.loadMs ?? null,
error: imageryHostStatus(getHost()) || lastError,
};
},
};
}
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const WEATHER_PRODUCTS = Object.freeze([
'radar',
'clouds',
'clouds-regional',
'lightning',
]);
/** Only bounded, explicit observations may become imagery requests. */
export function validateWeatherSnapshot(value, product) {
if (!value || value.schemaVersion !== 1 || value.product !== product)
throw new Error('Malformed weather manifest');
if (value.unavailable) return value;
const { bounds, times } = value;
if (
!bounds ||
!['west', 'south', 'east', 'north'].every((key) =>
Number.isFinite(bounds[key]),
) ||
bounds.west < -180 ||
bounds.east > 180 ||
bounds.south < -90 ||
bounds.north > 90 ||
bounds.west >= bounds.east ||
bounds.south >= bounds.north ||
!Array.isArray(times) ||
times.length < 1 ||
times.length > 13 ||
times.some(
(time, i) =>
typeof time !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(time) ||
!Number.isFinite(Date.parse(time)) ||
new Date(time).toISOString() !== time ||
(i > 0 && time <= times[i - 1]),
) ||
value.latest !== times.at(-1) ||
value.tileSize !== 256 ||
value.maxLevel !== 6 ||
value.tilingScheme !== 'geographic'
)
throw new Error('Malformed weather manifest');
return value;
}
/** Largest whole-extent image per product; also the proxy default size. */
export const WEATHER_IMAGE_SIZES = Object.freeze({
radar: Object.freeze({ width: 4096, height: 2048 }),
'clouds-regional': Object.freeze({ width: 4096, height: 2048 }),
clouds: Object.freeze({ width: 2048, height: 1024 }),
lightning: Object.freeze({ width: 4096, height: 2048 }),
});
/** Largest detail-window image for every product; also the proxy default. */
export const WEATHER_DETAIL_SIZE = Object.freeze({ width: 4096, height: 2048 });
/** A whole-extent image, or with `bbox` ({ west, south, east, north } degrees)
* a detail window of the same product. */
export function weatherImageUrl(
product,
time,
{ width, height } = {},
bbox = null,
) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
let box = '';
if (bbox !== null) {
const edges = [bbox.west, bbox.south, bbox.east, bbox.north];
if (
!edges.every(Number.isFinite) ||
edges[0] >= edges[2] ||
edges[1] >= edges[3]
)
throw new Error('Invalid weather window');
box = `&bbox=${edges.join(',')}`;
}
const largest =
bbox === null ? WEATHER_IMAGE_SIZES[product] : WEATHER_DETAIL_SIZE;
let size = '';
if (width !== undefined || height !== undefined) {
if (
![1024, 2048, 4096].includes(width) ||
height !== width / 2 ||
width > largest.width
)
throw new Error('Invalid weather image size');
// The largest size is the proxy default: one frame has one URL.
if (width !== largest.width) size = `&size=${width}x${height}`;
}
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${box}${size}`;
}
export function weatherTileUrl(product, time, { size } = {}) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
if (size !== undefined && ![256, 512, 1024].includes(size))
throw new Error('Invalid weather tile size');
// Construct locally; never accept a manifest-provided host or template.
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
}
/** Acquisition is lazy and shares the application's existing source contract. */
export function createWeatherSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ product = 'radar', signal } = {}) {
if (!WEATHER_PRODUCTS.includes(product))
throw new Error('Unknown weather product');
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Weather request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/weather/manifest?product=${product}`,
{ signal: controller.signal, cache: 'no-store', redirect: 'error' },
);
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
return validateWeatherSnapshot(
await readResponseJsonCapped(response, 16_384, controller.signal),
product,
);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherSource,
validateWeatherSnapshot,
weatherImageUrl,
weatherTileUrl,
WEATHER_DETAIL_SIZE,
WEATHER_IMAGE_SIZES,
} from './source.js';
const time = '2026-09-16T02:00:00.000Z';
const snapshot = () => ({
schemaVersion: 1,
product: 'radar',
bounds: { west: -130, south: 20, east: -60, north: 55 },
times: [time],
latest: time,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
});
test('observed source accepts bounded exact times and refuses malformed or unsorted frames', () => {
assert.equal(validateWeatherSnapshot(snapshot(), 'radar').latest, time);
for (const bad of [
{ times: [time, time] },
{ product: 'other' },
{ times: Array(14).fill(time) },
{ latest: 'tomorrow' },
{ maxLevel: 20 },
{ bounds: { west: -999, south: 0, east: 1, north: 1 } },
])
assert.throws(
() => validateWeatherSnapshot({ ...snapshot(), ...bad }, 'radar'),
/Malformed/,
);
});
test('weather tile URLs are same origin and ignore upstream templates', async () => {
const calls = [];
const source = createWeatherSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({
...snapshot(),
tileTemplate: 'https://example.invalid/{z}',
});
},
});
const result = await source.getSnapshot({ product: 'radar' });
assert.match(
weatherTileUrl(result.product, result.latest),
/^\/api\/weather\/tile\?product=radar&time=2026/,
);
assert.equal(calls[0].options.redirect, 'error');
assert.equal(calls[0].url, '/api/weather/manifest?product=radar');
});
test('source aborts before acquisition and caps streamed manifest bytes', async () => {
let calls = 0;
const source = createWeatherSource({
fetchImpl: async () => {
calls++;
return new Response(' '.repeat(17000));
},
});
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls, 0);
await assert.rejects(source.getSnapshot(), /too large/);
});
test('weather tile URLs carry only supported optional pixel sizes', () => {
assert.equal(
new URL(
weatherTileUrl('radar', time),
'https://example.test',
).searchParams.has('size'),
false,
);
for (const size of [256, 512, 1024]) {
const url = new URL(
weatherTileUrl('lightning', time, { size }),
'https://example.test',
);
assert.equal(url.searchParams.get('size'), String(size));
assert.equal(url.searchParams.get('time'), time);
}
for (const size of [0, 257, 2048, '1024', null])
assert.throws(() => weatherTileUrl('radar', time, { size }), /tile size/);
});
test('whole-extent image URLs are same origin and omit the default largest size', () => {
for (const [product, width] of [
['radar', 4096],
['clouds-regional', 4096],
['clouds', 2048],
['lightning', 4096],
]) {
assert.deepEqual(WEATHER_IMAGE_SIZES[product], {
width,
height: width / 2,
});
assert.equal(
weatherImageUrl(product, time),
`/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`,
);
assert.equal(
weatherImageUrl(product, time, { width, height: width / 2 }),
weatherImageUrl(product, time),
);
assert.equal(
new URL(
weatherImageUrl(product, time, { width: 1024, height: 512 }),
'https://example.test',
).searchParams.get('size'),
'1024x512',
);
}
for (const size of [
{ width: 8192, height: 4096 },
{ width: 2048, height: 2048 },
{ width: 512, height: 256 },
{ width: '1024', height: 512 },
])
assert.throws(() => weatherImageUrl('lightning', time, size), /size/);
assert.throws(
() => weatherImageUrl('clouds', time, { width: 4096, height: 2048 }),
/size/,
);
assert.throws(() => weatherImageUrl('other', time), /frame/);
assert.throws(() => weatherImageUrl('radar', 'latest'), /frame/);
});
test('detail-window image URLs carry the bbox and omit the default detail size', () => {
const box = { west: -102, south: 34, east: -96, north: 37 };
assert.deepEqual(WEATHER_DETAIL_SIZE, { width: 4096, height: 2048 });
for (const product of ['radar', 'clouds-regional', 'lightning', 'clouds']) {
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=-102,34,-96,37`;
assert.equal(weatherImageUrl(product, time, {}, box), url);
assert.equal(
weatherImageUrl(product, time, { width: 4096, height: 2048 }, box),
url,
'every product has a 4096×2048 detail default',
);
assert.equal(
weatherImageUrl(product, time, { width: 2048, height: 1024 }, box),
`${url}&size=2048x1024`,
);
}
assert.equal(
weatherImageUrl('radar', time, {}, { ...box, west: -97.5, east: -91.5 }),
`/api/weather/image?product=radar&time=${encodeURIComponent(time)}&bbox=-97.5,34,-91.5,37`,
);
for (const bad of [
{ ...box, west: -96 },
{ ...box, north: 34 },
{ ...box, east: Number.NaN },
{ west: -102, south: 34, east: -96 },
])
assert.throws(() => weatherImageUrl('radar', time, {}, bad), /window/);
assert.throws(
() => weatherImageUrl('radar', time, { width: 8192, height: 4096 }, box),
/size/,
);
});
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more