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