mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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
@@ -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/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
import { normalizeLongitude, sampleWind } from './model.js';
|
||||
|
||||
/** Fixed physical scales shared by imagery and the field legend. */
|
||||
export const WIND_FIELDS = Object.freeze({
|
||||
speed: Object.freeze({
|
||||
label: 'Wind speed',
|
||||
units: 'm/s',
|
||||
min: 0,
|
||||
max: 30,
|
||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: '2 m air temperature',
|
||||
units: '°C',
|
||||
min: -40,
|
||||
max: 50,
|
||||
// Fixed 10°C anchors keep model comparisons honest while making everyday
|
||||
// temperature gradients distinguishable from the underlying basemap.
|
||||
stops: [
|
||||
'#31235b',
|
||||
'#403b92',
|
||||
'#315fc1',
|
||||
'#268fce',
|
||||
'#31bdd0',
|
||||
'#8ad4aa',
|
||||
'#f0d255',
|
||||
'#ee8a34',
|
||||
'#d74638',
|
||||
'#9f274d',
|
||||
],
|
||||
}),
|
||||
pressure: Object.freeze({
|
||||
label: 'Mean sea-level pressure',
|
||||
units: 'hPa',
|
||||
min: 960,
|
||||
max: 1050,
|
||||
stops: ['#705ca0', '#6985b4', '#a5c3cd', '#d8c99a', '#dbad61'],
|
||||
}),
|
||||
});
|
||||
|
||||
function palette(stops) {
|
||||
const rgb = stops.map((color) =>
|
||||
color.match(/\w\w/g).map((value) => parseInt(value, 16)),
|
||||
);
|
||||
return Array.from({ length: 128 }, (_, i) => {
|
||||
const p = (i / 127) * (rgb.length - 1);
|
||||
const a = Math.min(rgb.length - 2, Math.floor(p));
|
||||
return rgb[a].map((value, channel) =>
|
||||
Math.round(value + (rgb[a + 1][channel] - value) * (p - a)),
|
||||
);
|
||||
});
|
||||
}
|
||||
const PALETTES = Object.fromEntries(
|
||||
Object.entries(WIND_FIELDS).map(([kind, spec]) => [
|
||||
kind,
|
||||
palette(spec.stops),
|
||||
]),
|
||||
);
|
||||
const WIND_COLORS = PALETTES.speed.map((rgb) => `rgb(${rgb.join(',')})`);
|
||||
const clamp = (x, low, high) => Math.max(low, Math.min(high, x));
|
||||
|
||||
/** No parsing, arrays or CSS interpolation in the particle loop. */
|
||||
export function windTrailColor(speed) {
|
||||
return WIND_COLORS[
|
||||
Math.round((clamp(Number.isFinite(speed) ? speed : 0, 0, 30) / 30) * 127)
|
||||
];
|
||||
}
|
||||
|
||||
/** Decay is a function of elapsed time, independent of refresh rate. */
|
||||
export function trailEraseAlpha(seconds, halfLife = 0.65) {
|
||||
return 1 - Math.pow(0.5, Math.max(0, seconds) / halfLife);
|
||||
}
|
||||
|
||||
/** Scalar units are already normalized by the source; do not convert twice. */
|
||||
export function sampleScalar(
|
||||
snapshot,
|
||||
lon,
|
||||
lat,
|
||||
kind = snapshot?.scalar?.kind ?? 'speed',
|
||||
) {
|
||||
const grid = snapshot?.grid ?? snapshot;
|
||||
if (!grid || !Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
if (kind === 'speed') {
|
||||
const wind = sampleWind(
|
||||
grid === snapshot ? grid : { ...grid, u: snapshot.u, v: snapshot.v },
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const speed = Math.hypot(wind.u, wind.v);
|
||||
return Number.isFinite(speed) ? speed : null;
|
||||
}
|
||||
const scalar = snapshot.scalar;
|
||||
if (
|
||||
!WIND_FIELDS[kind] ||
|
||||
scalar?.kind !== kind ||
|
||||
scalar.units !== WIND_FIELDS[kind].units ||
|
||||
!scalar.values
|
||||
)
|
||||
return null;
|
||||
const { nx, ny, lo1, la1, dx, dy } = grid;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = clamp((la1 - lat) / dy, 0, ny - 1);
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const col = (n) => ((n % nx) + nx) % nx;
|
||||
const y1 = Math.min(y0 + 1, ny - 1);
|
||||
const a = scalar.values[y0 * nx + col(x0)];
|
||||
const b = scalar.values[y0 * nx + col(x0 + 1)];
|
||||
const c = scalar.values[y1 * nx + col(x0)];
|
||||
const d = scalar.values[y1 * nx + col(x0 + 1)];
|
||||
const value =
|
||||
(a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** One bounded geographic texture per installed field, never per render frame. */
|
||||
export function createFieldRaster(snapshot, kind, width = 360, height = 181) {
|
||||
const spec = WIND_FIELDS[kind];
|
||||
if (
|
||||
!spec ||
|
||||
!snapshot ||
|
||||
!Number.isInteger(width) ||
|
||||
!Number.isInteger(height) ||
|
||||
width < 2 ||
|
||||
height < 2 ||
|
||||
width > 720 ||
|
||||
height > 362
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
kind !== 'speed' &&
|
||||
(snapshot.scalar?.kind !== kind || snapshot.scalar.units !== spec.units)
|
||||
)
|
||||
return null;
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
const flat = snapshot.grid
|
||||
? {
|
||||
...snapshot.grid,
|
||||
u: snapshot.u,
|
||||
v: snapshot.v,
|
||||
scalar: snapshot.scalar,
|
||||
}
|
||||
: snapshot;
|
||||
let finiteCount = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const lat = 90 - ((y + 0.5) / height) * 180;
|
||||
for (let x = 0; x < width; x++) {
|
||||
// Pixel centers cover -180..180; the source's 0-degree origin is not an image origin.
|
||||
const lon = -180 + ((x + 0.5) / width) * 360;
|
||||
const value = sampleScalar(flat, lon, lat, kind);
|
||||
if (value === null) continue;
|
||||
const t = clamp((value - spec.min) / (spec.max - spec.min), 0, 1);
|
||||
const rgb = PALETTES[kind][Math.round(t * 127)];
|
||||
const offset = (y * width + x) * 4;
|
||||
rgba[offset] = rgb[0];
|
||||
rgba[offset + 1] = rgb[1];
|
||||
rgba[offset + 2] = rgb[2];
|
||||
const alpha =
|
||||
kind === 'speed'
|
||||
? 0.22 + 0.43 * Math.sqrt(t)
|
||||
: kind === 'pressure'
|
||||
? 0.24 + 0.3 * Math.min(1, Math.abs(value - 1013) / 40)
|
||||
: 0.95;
|
||||
rgba[offset + 3] = Math.round(255 * alpha);
|
||||
finiteCount++;
|
||||
}
|
||||
}
|
||||
return finiteCount ? { width, height, rgba } : null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
WIND_FIELDS,
|
||||
createFieldRaster,
|
||||
sampleScalar,
|
||||
trailEraseAlpha,
|
||||
windTrailColor,
|
||||
} from './fields.js';
|
||||
import { sampleWind } from './model.js';
|
||||
|
||||
const snapshot = {
|
||||
grid: { nx: 4, ny: 2, lo1: 0, la1: 90, dx: 90, dy: 180 },
|
||||
u: new Float32Array(8).fill(3),
|
||||
v: new Float32Array(8).fill(4),
|
||||
scalar: {
|
||||
kind: 'temperature',
|
||||
units: '°C',
|
||||
values: new Float32Array([0, 10, 20, 30, -10, 0, 10, 20]),
|
||||
},
|
||||
};
|
||||
|
||||
test('scalar samples preserve normalized units, geometry, poles and the date-line seam', () => {
|
||||
assert.equal(sampleScalar(snapshot, 0, 90, 'speed'), 5);
|
||||
assert.equal(sampleScalar(snapshot, 0, 90, 'temperature'), 0);
|
||||
assert.equal(sampleScalar(snapshot, 90, -90, 'temperature'), 0);
|
||||
assert.equal(sampleScalar(snapshot, 180, 0, 'temperature'), 15);
|
||||
assert.equal(sampleScalar(snapshot, -180, 0, 'temperature'), 15);
|
||||
assert.equal(
|
||||
sampleScalar(snapshot, -0.01, 90, 'temperature'),
|
||||
sampleScalar(snapshot, 359.99, 90, 'temperature'),
|
||||
);
|
||||
assert.equal(sampleScalar(snapshot, 0, 0, 'pressure'), null);
|
||||
assert.equal(
|
||||
sampleScalar(
|
||||
{ ...snapshot, scalar: { ...snapshot.scalar, units: 'K' } },
|
||||
0,
|
||||
0,
|
||||
'temperature',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(sampleScalar(snapshot, Infinity, 0, 'speed'), null);
|
||||
});
|
||||
|
||||
test('raster samples geographic pixel centers, not the source zero-longitude origin', () => {
|
||||
const raster = createFieldRaster(snapshot, 'temperature', 4, 2);
|
||||
const shifted = {
|
||||
...snapshot,
|
||||
grid: { ...snapshot.grid, lo1: -180 },
|
||||
scalar: {
|
||||
...snapshot.scalar,
|
||||
values: new Float32Array([20, 30, 0, 10, 10, 20, -10, 0]),
|
||||
},
|
||||
};
|
||||
assert.deepEqual(raster, createFieldRaster(shifted, 'temperature', 4, 2));
|
||||
assert.equal(raster.rgba.length, 32);
|
||||
assert.notDeepEqual(
|
||||
[...raster.rgba.slice(0, 3)],
|
||||
[...raster.rgba.slice(8, 11)],
|
||||
);
|
||||
assert.equal(createFieldRaster(snapshot, 'pressure'), null);
|
||||
assert.equal(createFieldRaster(snapshot, 'speed', 4000, 2000), null);
|
||||
});
|
||||
|
||||
test('trail decay is independent of frame cadence and palette lookup clamps endpoints', () => {
|
||||
const remaining30 = (1 - trailEraseAlpha(1 / 30)) ** 30;
|
||||
const remaining60 = (1 - trailEraseAlpha(1 / 60)) ** 60;
|
||||
assert.ok(Math.abs(remaining30 - remaining60) < 1e-12);
|
||||
assert.ok(Math.abs(1 - trailEraseAlpha(0.65) - 0.5) < 1e-12);
|
||||
assert.equal(windTrailColor(-10), windTrailColor(0));
|
||||
assert.equal(windTrailColor(100), windTrailColor(30));
|
||||
assert.notEqual(windTrailColor(0), windTrailColor(30));
|
||||
});
|
||||
|
||||
test('hot-loop sampling fills the supplied output without allocating a replacement', () => {
|
||||
const result = {};
|
||||
assert.equal(
|
||||
sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
0,
|
||||
0,
|
||||
result,
|
||||
),
|
||||
result,
|
||||
);
|
||||
assert.deepEqual(result, { u: 3, v: 4 });
|
||||
});
|
||||
|
||||
|
||||
test('temperature uses a fixed Celsius scale with visible everyday gradients', () => {
|
||||
const spec = WIND_FIELDS.temperature;
|
||||
assert.equal(spec.units, '°C');
|
||||
assert.deepEqual(
|
||||
spec.stops.map((_, index) => spec.min + index * (spec.max - spec.min) / (spec.stops.length - 1)),
|
||||
[-40, -30, -20, -10, 0, 10, 20, 30, 40, 50],
|
||||
);
|
||||
const colorAt = (temperature) => {
|
||||
const field = { ...snapshot, scalar: { ...snapshot.scalar, values: new Float32Array(8).fill(temperature) } };
|
||||
assert.equal(sampleScalar(field, 0, 0, 'temperature'), temperature, 'display mapping does not change measurements');
|
||||
const raster = createFieldRaster(field, 'temperature', 2, 2);
|
||||
assert.ok(raster.rgba[3] >= 225, 'temperature color dominates the basemap rather than washing out');
|
||||
return [...raster.rgba.slice(0, 3)];
|
||||
};
|
||||
let previous = colorAt(0);
|
||||
for (const temperature of [5, 10, 15, 20, 25, 30, 35]) {
|
||||
const color = colorAt(temperature);
|
||||
const distance = Math.hypot(...color.map((value, index) => value - previous[index]));
|
||||
assert.ok(distance >= 30, `five-degree difference near ${temperature}°C remains visually distinct`);
|
||||
previous = color;
|
||||
}
|
||||
assert.deepEqual(colorAt(-60), colorAt(-40), 'only color saturates below the fixed scale');
|
||||
assert.deepEqual(colorAt(60), colorAt(50), 'only color saturates above the fixed scale');
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
bakeWindStreamlines,
|
||||
groupWindPaths,
|
||||
WIND_DISPLAY_HEIGHT_METERS,
|
||||
WIND_PATH_LIMIT,
|
||||
WIND_NARROW_PATH_LIMIT,
|
||||
} from './streamlines.js';
|
||||
|
||||
export const WIND_FADE_LOW_METERS = 15000;
|
||||
export const WIND_FADE_HIGH_METERS = 60000;
|
||||
|
||||
// Original material: a persistent fine curve plus a moving, tapered highlight.
|
||||
// The integer part of s identifies a path; its fractional part follows the wind.
|
||||
const FLOW_MATERIAL = `
|
||||
in float v_windFacing;
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float pathId = floor(materialInput.st.s);
|
||||
float along = fract(materialInput.st.s);
|
||||
float offset = fract((pathId + 1.0) * 0.61803398875);
|
||||
float behind = fract(phaseTime * 0.16 + offset - along);
|
||||
float tail = (1.0 - smoothstep(0.0, 0.32, behind));
|
||||
float tip = 1.0 - smoothstep(0.0, 0.045, behind);
|
||||
float ends = smoothstep(0.0, 0.055, along) * (1.0 - smoothstep(0.945, 1.0, along));
|
||||
float edge = 1.0 - smoothstep(0.26, 0.5, abs(materialInput.st.t - 0.5));
|
||||
float horizon = smoothstep(0.0, 0.065, v_windFacing);
|
||||
material.diffuse = mix(vec3(0.50, 0.79, 0.90), vec3(0.91, 0.99, 1.0), tip);
|
||||
material.alpha = (ghostAlpha + 0.64 * tail + 0.16 * tip) * ends * edge * horizon * heightFade;
|
||||
return material;
|
||||
}`;
|
||||
|
||||
/** Native Cesium geometry/material owner. No animation loop or DOM ownership. */
|
||||
export function createWindGpuRendering({ cesium: C, getViewer }) {
|
||||
const cells = [];
|
||||
let occluder = null;
|
||||
let collection = null;
|
||||
let material = null;
|
||||
let paused = false;
|
||||
let destroyed = false;
|
||||
const emptyDiagnostics = () => ({
|
||||
pathCount: 0,
|
||||
vertexCount: 0,
|
||||
buildMs: 0,
|
||||
error: null,
|
||||
cellCount: 0,
|
||||
visibleCells: 0,
|
||||
visibleVertexCount: 0,
|
||||
heightFade: 0,
|
||||
});
|
||||
let diagnostics = emptyDiagnostics();
|
||||
|
||||
function supported() {
|
||||
const scene = getViewer?.()?.scene;
|
||||
return (
|
||||
!destroyed &&
|
||||
Boolean(
|
||||
scene?.primitives &&
|
||||
C?.Primitive &&
|
||||
C?.GeometryInstance &&
|
||||
C?.GeometryInstanceAttribute &&
|
||||
C?.ComponentDatatype?.FLOAT !== undefined &&
|
||||
C?.PolylineGeometry &&
|
||||
C?.PolylineMaterialAppearance &&
|
||||
C?.Material &&
|
||||
C?.BoundingSphere?.fromPoints &&
|
||||
C?.Occluder &&
|
||||
C?.Ellipsoid?.WGS84 &&
|
||||
C?.Intersect &&
|
||||
C?.Cartesian3?.fromDegrees,
|
||||
) &&
|
||||
(C.SceneMode?.SCENE3D === undefined ||
|
||||
scene.mode === undefined ||
|
||||
scene.mode === C.SceneMode.SCENE3D)
|
||||
);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const cell of cells) {
|
||||
collection?.remove(cell.primitive);
|
||||
if (!cell.primitive.isDestroyed?.()) cell.primitive.destroy?.();
|
||||
}
|
||||
cells.length = 0;
|
||||
occluder = null;
|
||||
collection = null;
|
||||
material?.destroy?.();
|
||||
material = null;
|
||||
diagnostics = emptyDiagnostics();
|
||||
}
|
||||
|
||||
function setField(field) {
|
||||
clear();
|
||||
if (!supported() || !field) return false;
|
||||
const started = globalThis.performance?.now?.() ?? Date.now();
|
||||
try {
|
||||
const width = getViewer().scene.canvas?.clientWidth;
|
||||
const budget =
|
||||
width > 0 && width < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
|
||||
const paths = bakeWindStreamlines(field, { count: budget });
|
||||
if (!paths.length) return false;
|
||||
let vertexCount = 0;
|
||||
material = new C.Material({
|
||||
fabric: {
|
||||
type: 'GevWindStreamline',
|
||||
uniforms: {
|
||||
phaseTime: 0,
|
||||
ghostAlpha: paused ? 0.34 : 0.25,
|
||||
heightFade: 0,
|
||||
},
|
||||
source: FLOW_MATERIAL,
|
||||
},
|
||||
translucent: () => true,
|
||||
});
|
||||
const base = new C.PolylineMaterialAppearance({ material });
|
||||
// Extend the public default shader rather than duplicate Cesium's polyline
|
||||
// expansion/precision code. Explicit facing also masks the far hemisphere
|
||||
// when a scene chooses not to preserve globe depth for translucent objects.
|
||||
const source = base.vertexShaderSource;
|
||||
if (
|
||||
!/void\s+main\s*\(\s*\)/.test(source) ||
|
||||
!/v_st\.s\s*=\s*st\.s\s*;/.test(source)
|
||||
)
|
||||
throw new Error('Wind polyline shader entry unavailable');
|
||||
const seededSource = source.replace(
|
||||
/v_st\.s\s*=\s*st\.s\s*;/,
|
||||
'v_st.s = st.s * 0.999 + czm_batchTable_windSeed(batchId);',
|
||||
);
|
||||
const vertexShaderSource =
|
||||
`out float v_windFacing;\n${seededSource}`.replace(
|
||||
/void\s+main\s*\(\s*\)\s*\{/,
|
||||
`void main() {\nvec3 windWorld = (czm_model * vec4(position3DHigh + position3DLow, 1.0)).xyz;\nv_windFacing = dot(normalize(windWorld), normalize(czm_viewerPositionWC - windWorld));\n`,
|
||||
);
|
||||
const scene = getViewer().scene;
|
||||
collection = scene.primitives;
|
||||
// EllipsoidalOccluder only tests points. Use Cesium's sphere occluder
|
||||
// with an inscribed Earth sphere, independent of scene.globe.show.
|
||||
occluder = new C.Occluder(
|
||||
new C.BoundingSphere(
|
||||
C.Cartesian3.ZERO,
|
||||
C.Ellipsoid.WGS84.minimumRadius,
|
||||
),
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
for (const group of groupWindPaths(paths)) {
|
||||
const cellPositions = [];
|
||||
let cellVertexCount = 0;
|
||||
const instances = group.paths.map((path) => {
|
||||
const positions = path.coordinates.map(([lon, lat]) =>
|
||||
C.Cartesian3.fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS),
|
||||
);
|
||||
// Pass the native description to Cesium's worker. Per-instance seed
|
||||
// attributes avoid touching the expanded vertex buffer on the UI thread.
|
||||
const geometry = new C.PolylineGeometry({
|
||||
positions,
|
||||
width: 2.4,
|
||||
arcType: C.ArcType?.NONE,
|
||||
vertexFormat: C.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
cellPositions.push(...positions);
|
||||
cellVertexCount += positions.length * 4 - 4;
|
||||
return new C.GeometryInstance({
|
||||
geometry,
|
||||
attributes: {
|
||||
windSeed: new C.GeometryInstanceAttribute({
|
||||
componentDatatype: C.ComponentDatatype.FLOAT,
|
||||
componentsPerAttribute: 1,
|
||||
value: [path.seed],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
const appearance = new C.PolylineMaterialAppearance({
|
||||
material,
|
||||
vertexShaderSource,
|
||||
translucent: true,
|
||||
renderState: { depthTest: { enabled: true }, depthMask: false },
|
||||
});
|
||||
const primitive = new C.Primitive({
|
||||
show: false,
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: true,
|
||||
compressVertices: false,
|
||||
});
|
||||
const cell = {
|
||||
primitive,
|
||||
sphere: C.BoundingSphere.fromPoints(cellPositions),
|
||||
vertexCount: cellVertexCount,
|
||||
};
|
||||
cells.push(cell);
|
||||
collection.add(primitive);
|
||||
vertexCount += cellVertexCount;
|
||||
}
|
||||
diagnostics = {
|
||||
...emptyDiagnostics(),
|
||||
cellCount: cells.length,
|
||||
pathCount: paths.length,
|
||||
vertexCount,
|
||||
buildMs: (globalThis.performance?.now?.() ?? Date.now()) - started,
|
||||
error: null,
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
clear();
|
||||
diagnostics.error =
|
||||
error instanceof Error ? error.message : 'Wind geometry unavailable';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cesium reuses its frustum culling volume and the occluder's scratch vectors.
|
||||
// This loop creates no positions, spheres, appearances or per-cell temporaries.
|
||||
function updateVisibility(camera) {
|
||||
const height = camera.positionCartographic.height;
|
||||
const heightFade = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(height - WIND_FADE_LOW_METERS) /
|
||||
(WIND_FADE_HIGH_METERS - WIND_FADE_LOW_METERS),
|
||||
),
|
||||
);
|
||||
diagnostics.heightFade = heightFade;
|
||||
if (material) material.uniforms.heightFade = heightFade;
|
||||
diagnostics.visibleCells = 0;
|
||||
diagnostics.visibleVertexCount = 0;
|
||||
let volume;
|
||||
if (heightFade > 0 && cells.length) {
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
volume = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
cell.primitive.show =
|
||||
heightFade > 0 &&
|
||||
occluder.isBoundingSphereVisible(cell.sphere) &&
|
||||
volume.computeVisibility(cell.sphere) !== C.Intersect.OUTSIDE;
|
||||
if (cell.primitive.show) {
|
||||
diagnostics.visibleCells++;
|
||||
diagnostics.visibleVertexCount += cell.vertexCount;
|
||||
}
|
||||
}
|
||||
return diagnostics.visibleCells > 0 && heightFade > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
supported,
|
||||
setField,
|
||||
updateVisibility,
|
||||
tick(elapsedSeconds) {
|
||||
if (material && !paused && Number.isFinite(elapsedSeconds))
|
||||
material.uniforms.phaseTime = Math.max(0, elapsedSeconds) % 10000;
|
||||
},
|
||||
setOptions(options = {}) {
|
||||
if (typeof options.paused === 'boolean') paused = options.paused;
|
||||
if (material) material.uniforms.ghostAlpha = paused ? 0.34 : 0.25;
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
clear();
|
||||
destroyed = true;
|
||||
},
|
||||
getParticleCount: () => diagnostics.pathCount,
|
||||
getDiagnostics: () => ({
|
||||
...diagnostics,
|
||||
ready: cells.length > 0 && cells.every((cell) => cell.primitive.ready),
|
||||
mode: 'gpu-streamlines',
|
||||
displayHeightMeters: WIND_DISPLAY_HEIGHT_METERS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import {
|
||||
BoundingSphere,
|
||||
Cartesian3,
|
||||
Ellipsoid,
|
||||
Intersect,
|
||||
Occluder,
|
||||
PerspectiveFrustum,
|
||||
} from 'cesium';
|
||||
import { bakeWindStreamlines } from './streamlines.js';
|
||||
|
||||
function harness({ width = 1000 } = {}) {
|
||||
const members = new Set();
|
||||
let geometryBuilds = 0;
|
||||
const spheres = [];
|
||||
const visibility = {
|
||||
horizon: () => true,
|
||||
frustum: () => Intersect.INSIDE,
|
||||
horizonTests: 0,
|
||||
frustumTests: 0,
|
||||
};
|
||||
let occluderBuilds = 0;
|
||||
class Material {
|
||||
constructor(options) {
|
||||
this.uniforms = options.fabric.uniforms;
|
||||
this.source = options.fabric.source;
|
||||
}
|
||||
destroy() {
|
||||
assert.ok(!this.destroyed, 'owned resource destroyed once');
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class Appearance {
|
||||
static VERTEX_FORMAT = {};
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.vertexShaderSource ??=
|
||||
'in vec3 position3DHigh; in vec3 position3DLow; void main() { gl_Position = vec4(0.0); v_st.s = st.s; }';
|
||||
}
|
||||
}
|
||||
class Polyline {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
static createGeometry(options) {
|
||||
geometryBuilds++;
|
||||
const st = new Float32Array(options.positions.length * 2);
|
||||
for (let i = 0; i < options.positions.length; i++)
|
||||
st[i * 2] = i / (options.positions.length - 1);
|
||||
return { attributes: { st: { values: st } } };
|
||||
}
|
||||
}
|
||||
class Primitive {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.ready = true;
|
||||
}
|
||||
destroy() {
|
||||
assert.ok(!this.destroyed, 'owned resource destroyed once');
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return Boolean(this.destroyed);
|
||||
}
|
||||
}
|
||||
const C = {
|
||||
ComponentDatatype: { FLOAT: 5126 },
|
||||
GeometryInstanceAttribute: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
Material,
|
||||
PolylineMaterialAppearance: Appearance,
|
||||
PolylineGeometry: Polyline,
|
||||
Primitive,
|
||||
GeometryInstance: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
Cartesian3,
|
||||
Ellipsoid,
|
||||
Intersect,
|
||||
BoundingSphere: class extends BoundingSphere {
|
||||
static fromPoints(points) {
|
||||
const sphere = BoundingSphere.fromPoints(points);
|
||||
spheres.push({ sphere, points });
|
||||
return sphere;
|
||||
}
|
||||
},
|
||||
Occluder: class {
|
||||
constructor(sphere, cameraPosition) {
|
||||
occluderBuilds++;
|
||||
this.cameraPosition = cameraPosition;
|
||||
}
|
||||
isBoundingSphereVisible(sphere) {
|
||||
visibility.horizonTests++;
|
||||
return visibility.horizon(sphere, this.cameraPosition);
|
||||
}
|
||||
},
|
||||
ArcType: { NONE: 0 },
|
||||
};
|
||||
const volume = {
|
||||
computeVisibility(sphere) {
|
||||
visibility.frustumTests++;
|
||||
return visibility.frustum(sphere);
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
globe: { show: false },
|
||||
camera: {
|
||||
positionWC: Cartesian3.fromDegrees(0, 0, 1e7),
|
||||
positionCartographic: { height: 1e7 },
|
||||
directionWC: new Cartesian3(-1, 0, 0),
|
||||
upWC: new Cartesian3(0, 0, 1),
|
||||
frustum: { computeCullingVolume: () => volume },
|
||||
},
|
||||
canvas: { clientWidth: width },
|
||||
primitives: {
|
||||
add(item) {
|
||||
members.add(item);
|
||||
},
|
||||
remove(item) {
|
||||
const removed = members.delete(item);
|
||||
if (removed) item.destroy();
|
||||
return removed;
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
C,
|
||||
scene,
|
||||
members,
|
||||
builds: () => geometryBuilds,
|
||||
spheres,
|
||||
visibility,
|
||||
occluderBuilds: () => occluderBuilds,
|
||||
owner: createWindGpuRendering({ cesium: C, getViewer: () => ({ scene }) }),
|
||||
};
|
||||
}
|
||||
const field = () => ({
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
u: new Float32Array(12).fill(15),
|
||||
v: new Float32Array(12),
|
||||
});
|
||||
|
||||
test('GPU owner builds regional native batches; ticks update uniforms without rebuilding geometry', () => {
|
||||
const h = harness();
|
||||
assert.equal(h.owner.setField(field()), true);
|
||||
assert.equal(h.members.size, 72);
|
||||
const batch = [...h.members][0];
|
||||
assert.equal(batch.allowPicking, false);
|
||||
assert.equal(batch.asynchronous, true);
|
||||
assert.ok(batch.appearance.vertexShaderSource.includes('v_windFacing = dot'));
|
||||
assert.ok(
|
||||
batch.appearance.material.source.includes(
|
||||
'smoothstep(0.0, 0.065, v_windFacing)',
|
||||
),
|
||||
);
|
||||
const builds = h.builds();
|
||||
assert.equal(builds, 0, 'expanded geometry is deferred to Cesium workers');
|
||||
assert.ok(
|
||||
batch.appearance.vertexShaderSource.includes(
|
||||
'czm_batchTable_windSeed(batchId)',
|
||||
),
|
||||
);
|
||||
h.owner.tick(3);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
|
||||
h.owner.setOptions({ paused: true });
|
||||
h.owner.tick(8);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
|
||||
h.owner.setOptions({ paused: false });
|
||||
h.owner.tick(9);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 9);
|
||||
assert.equal(h.builds(), builds);
|
||||
const diagnostics = h.owner.getDiagnostics();
|
||||
assert.ok(diagnostics.pathCount <= 7200);
|
||||
assert.ok(
|
||||
diagnostics.pathCount > 7000,
|
||||
'desktop retains the requested doubled global density',
|
||||
);
|
||||
assert.ok(diagnostics.vertexCount > 0);
|
||||
assert.ok(
|
||||
diagnostics.vertexCount <= 7200 * 128,
|
||||
'expanded geometry remains bounded',
|
||||
);
|
||||
assert.equal(diagnostics.ready, true);
|
||||
assert.equal(diagnostics.displayHeightMeters, 12000);
|
||||
assert.equal(diagnostics.cellCount, 72);
|
||||
assert.equal(h.spheres.length, 72);
|
||||
assert.equal(new Set([...h.members].map((p) => p.appearance)).size, 72);
|
||||
for (const cell of h.members) {
|
||||
assert.equal(cell.appearance.material, batch.appearance.material);
|
||||
assert.equal(cell.appearance.material.uniforms.phaseTime, 9);
|
||||
assert.equal(cell.appearance.material.uniforms.ghostAlpha, 0.25);
|
||||
assert.equal(cell.compressVertices, false);
|
||||
assert.equal(cell.releaseGeometryInstances, true);
|
||||
}
|
||||
batch.ready = false;
|
||||
assert.equal(
|
||||
h.owner.getDiagnostics().ready,
|
||||
false,
|
||||
'every cell must be ready',
|
||||
);
|
||||
batch.ready = true;
|
||||
const instances = [...h.members].flatMap((p) => p.geometryInstances);
|
||||
const baked = bakeWindStreamlines(field());
|
||||
assert.equal(instances.length, baked.length);
|
||||
const bySeed = new Map(
|
||||
instances.map((i) => [i.attributes.windSeed.value[0], i]),
|
||||
);
|
||||
for (const path of baked) {
|
||||
assert.deepEqual(
|
||||
bySeed.get(path.seed).geometry.positions,
|
||||
path.coordinates.map(([lon, lat]) =>
|
||||
Cartesian3.fromDegrees(lon, lat, 12000),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const { sphere, points } of h.spheres) {
|
||||
assert.deepEqual(sphere, BoundingSphere.fromPoints(points));
|
||||
for (const point of points)
|
||||
assert.ok(
|
||||
Cartesian3.distance(point, sphere.center) <= sphere.radius + 1e-6,
|
||||
);
|
||||
}
|
||||
for (const instance of instances) {
|
||||
assert.ok(instance.geometry instanceof h.C.PolylineGeometry);
|
||||
assert.equal(instance.geometry.width, 2.4);
|
||||
assert.equal(instance.attributes.windSeed.componentsPerAttribute, 1);
|
||||
assert.ok(Number.isInteger(instance.attributes.windSeed.value[0]));
|
||||
}
|
||||
});
|
||||
|
||||
test('replacement, clear and destroy dispose every owned cell and the shared material', () => {
|
||||
const h = harness();
|
||||
const assertDisposed = (batches) => {
|
||||
assert.equal(batches.length, 72);
|
||||
for (const batch of batches) {
|
||||
assert.equal(batch.destroyed, true);
|
||||
assert.equal(batch.appearance.material.destroyed, true);
|
||||
}
|
||||
};
|
||||
h.owner.setField(field());
|
||||
const first = [...h.members];
|
||||
h.owner.setField(field());
|
||||
assertDisposed(first);
|
||||
const second = [...h.members];
|
||||
h.owner.clear();
|
||||
assertDisposed(second);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(h.owner.getParticleCount(), 0);
|
||||
assert.equal(h.owner.getDiagnostics().ready, false);
|
||||
h.owner.setField(field());
|
||||
const third = [...h.members];
|
||||
h.owner.destroy();
|
||||
assertDisposed(third);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(h.owner.supported(), false);
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
});
|
||||
|
||||
test('unsupported or failed geometry cleanly requests the existing fallback', () => {
|
||||
const missing = createWindGpuRendering({
|
||||
cesium: {},
|
||||
getViewer: () => ({ scene: {} }),
|
||||
});
|
||||
assert.equal(missing.setField(field()), false);
|
||||
const h = harness();
|
||||
h.C.PolylineGeometry = class {
|
||||
constructor() {
|
||||
throw new Error('test build failure');
|
||||
}
|
||||
};
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.match(h.owner.getDiagnostics().error, /test build failure/);
|
||||
});
|
||||
|
||||
test('narrow canvases use a lower bounded path budget', () => {
|
||||
const h = harness({ width: 390 });
|
||||
assert.equal(h.owner.setField(field()), true);
|
||||
assert.ok(h.owner.getParticleCount() <= 1200);
|
||||
assert.ok(h.owner.getParticleCount() > 1000);
|
||||
});
|
||||
|
||||
test('cell visibility combines horizon, frustum and height with shared fade uniforms', () => {
|
||||
const h = harness();
|
||||
h.owner.setField(field());
|
||||
const batches = [...h.members];
|
||||
const camera = h.scene.camera;
|
||||
h.visibility.horizon = (sphere) =>
|
||||
h.spheres.findIndex((s) => s.sphere === sphere) % 2 === 0;
|
||||
h.visibility.frustum = (sphere) =>
|
||||
h.spheres.findIndex((s) => s.sphere === sphere) % 3 === 0
|
||||
? Intersect.INTERSECTING
|
||||
: Intersect.OUTSIDE;
|
||||
assert.equal(h.owner.updateVisibility(camera), true);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 12);
|
||||
let vertices = 0;
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
assert.equal(batches[i].show, i % 6 === 0);
|
||||
if (batches[i].show)
|
||||
vertices += batches[i].geometryInstances.reduce(
|
||||
(sum, p) => sum + p.geometry.positions.length * 4 - 4,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert.equal(h.owner.getDiagnostics().visibleVertexCount, vertices);
|
||||
assert.equal(h.visibility.horizonTests, 72);
|
||||
assert.equal(h.visibility.frustumTests, 36);
|
||||
for (const [height, fade] of [
|
||||
[60000, 1],
|
||||
[37500, 0.5],
|
||||
[15000, 0],
|
||||
[1200, 0],
|
||||
]) {
|
||||
camera.positionCartographic.height = height;
|
||||
assert.equal(h.owner.updateVisibility(camera), fade > 0);
|
||||
assert.equal(h.owner.getDiagnostics().heightFade, fade);
|
||||
for (const batch of batches) {
|
||||
assert.equal(batch.appearance.material.uniforms.heightFade, fade);
|
||||
assert.match(batch.appearance.material.source, /horizon \* heightFade/);
|
||||
if (!fade) assert.equal(batch.show, false);
|
||||
}
|
||||
}
|
||||
assert.equal(h.owner.getDiagnostics().visibleVertexCount, 0);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
|
||||
assert.equal(
|
||||
h.visibility.horizonTests,
|
||||
216,
|
||||
'zero fade skips all sphere tests',
|
||||
);
|
||||
assert.equal(h.visibility.frustumTests, 108);
|
||||
assert.equal(
|
||||
h.occluderBuilds(),
|
||||
1,
|
||||
'occluder is reused across camera updates',
|
||||
);
|
||||
});
|
||||
|
||||
test('real Cesium sphere culling hides far hemisphere with globe hidden and rejects a sky-facing view', () => {
|
||||
const h = harness();
|
||||
h.C.Occluder = Occluder;
|
||||
h.owner.setField(field());
|
||||
const camera = h.scene.camera;
|
||||
camera.frustum = new PerspectiveFrustum({
|
||||
fov: Math.PI / 3,
|
||||
aspectRatio: 1.5,
|
||||
near: 1,
|
||||
far: 5e7,
|
||||
});
|
||||
assert.equal(h.owner.updateVisibility(camera), true);
|
||||
const facingEarth = h.owner.getDiagnostics();
|
||||
assert.ok(facingEarth.visibleCells > 0 && facingEarth.visibleCells < 72);
|
||||
for (let i = 0; i < h.spheres.length; i++) {
|
||||
const sphere = h.spheres[i].sphere;
|
||||
if (sphere.center.x < -5e6) assert.equal([...h.members][i].show, false);
|
||||
}
|
||||
camera.directionWC.x = 1;
|
||||
assert.equal(h.owner.updateVisibility(camera), false);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
|
||||
camera.positionWC = Cartesian3.fromDegrees(180, 0, 1e7);
|
||||
assert.equal(
|
||||
h.owner.updateVisibility(camera),
|
||||
true,
|
||||
'camera position updates the owned horizon test',
|
||||
);
|
||||
h.owner.destroy();
|
||||
});
|
||||
|
||||
test('a failed later cell build releases already installed cells and shared material', () => {
|
||||
const h = harness();
|
||||
const NativePrimitive = h.C.Primitive;
|
||||
const built = [];
|
||||
h.C.Primitive = class extends NativePrimitive {
|
||||
constructor(options) {
|
||||
if (built.length === 2) throw new Error('later cell failed');
|
||||
super(options);
|
||||
built.push(this);
|
||||
}
|
||||
};
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(built.length, 2);
|
||||
for (const batch of built) {
|
||||
assert.equal(batch.destroyed, true);
|
||||
assert.equal(batch.appearance.material.destroyed, true);
|
||||
}
|
||||
assert.match(h.owner.getDiagnostics().error, /later cell failed/);
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWindRendering } from './rendering.js';
|
||||
import { WIND_FIELDS } from './fields.js';
|
||||
import {
|
||||
formatWindReading,
|
||||
windReadingResult,
|
||||
windUnitChips,
|
||||
} from './presentation.js';
|
||||
import {
|
||||
inspectWindAtCenter,
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms)
|
||||
? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Forecast issue time is source age; valid time is reported separately. */
|
||||
export function windStats(manifest) {
|
||||
const run = Date.parse(manifest?.cycle?.runIso);
|
||||
return {
|
||||
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
||||
lastUpdate: Number.isFinite(run) ? run : null,
|
||||
error:
|
||||
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct one wind layer with an explicit source and owned animation. */
|
||||
export function createWindLayer({
|
||||
feed,
|
||||
clock,
|
||||
cesium = Cesium,
|
||||
container,
|
||||
createRendering = createWindRendering,
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wind requires a snapshot source');
|
||||
let viewer = null;
|
||||
let request = null;
|
||||
let enabled = false;
|
||||
let rendering = null;
|
||||
let imageryHost = null;
|
||||
let manifest = null;
|
||||
let error = null;
|
||||
let loading = false;
|
||||
let model = 'gfs';
|
||||
let overlay = 'none';
|
||||
let paused = false;
|
||||
let units = 'km/h';
|
||||
let reading = null;
|
||||
let inspectionMarker = null;
|
||||
let sampledPosition = null;
|
||||
const hideInspection = () => {
|
||||
reading = null;
|
||||
sampledPosition = null;
|
||||
inspectionMarker?.clear();
|
||||
};
|
||||
const sample = (position) => {
|
||||
reading = inspectWindAtCenter(manifest, viewer, cesium, {
|
||||
position,
|
||||
units,
|
||||
overlay,
|
||||
model: model === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : 'Model forecast'),
|
||||
});
|
||||
sampledPosition = reading.position || position || null;
|
||||
inspectionMarker?.show(sampledPosition);
|
||||
};
|
||||
const requestedScalar = () =>
|
||||
['temperature', 'pressure'].includes(overlay) ? overlay : 'none';
|
||||
let generation = 0;
|
||||
let rowControlsListener = null;
|
||||
const notify = () => rowControlsListener?.();
|
||||
const unsubscribeClock = clock?.subscribe(notify);
|
||||
const layer = {
|
||||
id: 'wind',
|
||||
name: 'Wind',
|
||||
icon: '🌬',
|
||||
source: 'GFS / ECMWF IFS · FORECAST',
|
||||
updateInterval: 3600_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({
|
||||
cesium,
|
||||
container: container ?? nextViewer.container,
|
||||
getViewer: () => viewer,
|
||||
getHost: () =>
|
||||
imageryHost?.() ?? {
|
||||
collection: viewer.imageryLayers ?? viewer.scene?.imageryLayers,
|
||||
kind: 'globe',
|
||||
},
|
||||
onStatusChange: notify,
|
||||
});
|
||||
rendering.attach();
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
const target = container ?? nextViewer.container;
|
||||
inspectionMarker = createWindInspectionMarker({
|
||||
container: target,
|
||||
viewer,
|
||||
cesium,
|
||||
});
|
||||
},
|
||||
attachShellServices(services) {
|
||||
imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
rendering?.rehome?.();
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
rendering?.start();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
generation += 1;
|
||||
request?.abort();
|
||||
request = null;
|
||||
loading = false;
|
||||
// The renderer releases its field on disable. Retaining a manifest here
|
||||
// would falsely satisfy an appearance-only reuse after the next enable.
|
||||
manifest = null;
|
||||
error = null;
|
||||
hideInspection();
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(nextViewer, { signal } = {}) {
|
||||
if (!enabled) return false;
|
||||
rendering?.rehome?.();
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
if (signal?.aborted) controller.abort(signal.reason);
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
request = controller;
|
||||
generation += 1;
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await feed.getSnapshot({
|
||||
signal: controller.signal,
|
||||
model,
|
||||
overlay: requestedScalar(),
|
||||
});
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
if (sampledPosition) sample(sampledPosition);
|
||||
if (snapshot.unavailable) {
|
||||
rendering.stop();
|
||||
rendering.clear();
|
||||
} else {
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
rendering.setField(snapshot);
|
||||
rendering.start();
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Wind source unavailable';
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
const previousScalar = requestedScalar();
|
||||
const modelChanged =
|
||||
['gfs', 'ifs'].includes(params.model) && params.model !== model;
|
||||
const overlayChanged =
|
||||
['none', 'speed', 'temperature', 'pressure'].includes(params.overlay) &&
|
||||
params.overlay !== overlay;
|
||||
if (typeof params.paused === 'boolean') paused = params.paused;
|
||||
const unitsChanged =
|
||||
Object.hasOwn(WIND_UNITS, params.units) && params.units !== units;
|
||||
if (unitsChanged) units = params.units;
|
||||
if (modelChanged) model = params.model;
|
||||
if (overlayChanged) overlay = params.overlay;
|
||||
rendering?.setOptions?.({ overlay, paused });
|
||||
if (unitsChanged) reading = formatWindReading(reading, units);
|
||||
if (params.inspect === false) hideInspection();
|
||||
if (modelChanged) {
|
||||
manifest = null;
|
||||
error = null;
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
}
|
||||
const needsField =
|
||||
modelChanged ||
|
||||
(overlayChanged &&
|
||||
(!manifest?.u ||
|
||||
manifest.unavailable ||
|
||||
(requestedScalar() !== 'none' &&
|
||||
manifest?.scalar?.kind !== overlay)));
|
||||
const changesRequest =
|
||||
modelChanged ||
|
||||
(overlayChanged && previousScalar !== requestedScalar());
|
||||
// None and Speed use the identical U/V request. Keep an active refresh
|
||||
// alive while changing its presentation, including the first load.
|
||||
if (changesRequest || (overlayChanged && needsField && !request)) {
|
||||
request?.abort();
|
||||
request = null;
|
||||
const current = ++generation;
|
||||
loading = enabled && needsField;
|
||||
if (enabled && needsField)
|
||||
queueMicrotask(() => {
|
||||
if (enabled && generation === current) void layer.update(viewer);
|
||||
});
|
||||
else if (enabled && manifest && !manifest.unavailable)
|
||||
rendering?.start();
|
||||
}
|
||||
if (overlayChanged && !needsField && sampledPosition)
|
||||
sample(sampledPosition);
|
||||
if (params.inspect === true && enabled) {
|
||||
sample();
|
||||
}
|
||||
notify();
|
||||
},
|
||||
getParams() {
|
||||
return { model, overlay, paused, units };
|
||||
},
|
||||
getRowControls() {
|
||||
const historyStatus =
|
||||
clock?.getState().mode === 'history'
|
||||
? 'Forecast · does not follow history'
|
||||
: null;
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
const motionPaused = paused || diagnostic?.reducedMotion;
|
||||
const preparing =
|
||||
enabled &&
|
||||
diagnostic?.renderMode === 'gpu-streamlines' &&
|
||||
diagnostic.gpu?.pathCount > 0 &&
|
||||
!diagnostic.gpu?.ready;
|
||||
const scalarMissing =
|
||||
!loading &&
|
||||
requestedScalar() !== 'none' &&
|
||||
manifest &&
|
||||
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
||||
const imageryError =
|
||||
enabled && !loading ? diagnostic?.imageryError : null;
|
||||
const label = {
|
||||
none: 'Wind motion',
|
||||
speed: 'Wind speed',
|
||||
pressure: 'Sea-level pressure',
|
||||
temperature: 'Air temperature · 2 m',
|
||||
}[overlay];
|
||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||
? overlay
|
||||
: 'speed';
|
||||
const spec = WIND_FIELDS[kind];
|
||||
const legendUnit = kind === 'speed' ? units : spec.units;
|
||||
const legend = spec.stops.map((color, index) => {
|
||||
const value =
|
||||
spec.min + ((spec.max - spec.min) * index) / (spec.stops.length - 1);
|
||||
const display = Math.round(
|
||||
kind === 'speed' ? value * WIND_UNITS[units] : value,
|
||||
);
|
||||
return {
|
||||
color,
|
||||
label: `${display}${index === spec.stops.length - 1 ? '+' : ''}`,
|
||||
};
|
||||
});
|
||||
const speedLegendVisible =
|
||||
!imageryError &&
|
||||
(overlay === 'speed' ||
|
||||
(overlay === 'none' && diagnostic?.renderMode === 'canvas-fallback'));
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label,
|
||||
coverage: 'Global · 1° grid',
|
||||
validTime: manifest?.cycle?.validIso,
|
||||
issuedTime: manifest?.cycle?.runIso,
|
||||
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: preparing
|
||||
? 'Preparing flow'
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
imageryError ||
|
||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
||||
units: legendUnit,
|
||||
},
|
||||
chips: [
|
||||
...['gfs', 'ifs'].map((value) => ({
|
||||
id: `model-${value}`,
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
active: model === value,
|
||||
params: { model: value },
|
||||
title:
|
||||
value === 'ifs'
|
||||
? 'ECMWF IFS surface forecast'
|
||||
: 'NOAA GFS surface forecast',
|
||||
})),
|
||||
{
|
||||
id: 'motion',
|
||||
label: diagnostic?.reducedMotion
|
||||
? 'Reduced motion'
|
||||
: paused
|
||||
? 'Resume'
|
||||
: 'Pause',
|
||||
active: motionPaused,
|
||||
disabled: Boolean(diagnostic?.reducedMotion),
|
||||
params: { paused: !paused },
|
||||
title:
|
||||
'Pause visual flow; forecast time does not advance with animation',
|
||||
},
|
||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||
id: `overlay-${value}`,
|
||||
label: {
|
||||
none: 'None',
|
||||
speed: 'Speed',
|
||||
pressure: 'Pressure',
|
||||
temperature: 'Temperature',
|
||||
}[value],
|
||||
active: overlay === value,
|
||||
params: { overlay: value },
|
||||
title: {
|
||||
none: 'Wind trails with no field shading',
|
||||
speed: 'How hard the surface wind is blowing',
|
||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
||||
temperature: 'Air temperature two meters above the surface',
|
||||
}[value],
|
||||
})),
|
||||
...(speedLegendVisible ||
|
||||
diagnostic?.renderMode === 'canvas-fallback' ||
|
||||
Number.isFinite(reading?.speed)
|
||||
? Object.keys(WIND_UNITS)
|
||||
: []
|
||||
).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
})),
|
||||
{
|
||||
id: 'read-wind',
|
||||
label: 'Read wind at map center',
|
||||
params: { inspect: true },
|
||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||
title:
|
||||
'Read the forecast at the center of the map without changing selection',
|
||||
},
|
||||
],
|
||||
legend:
|
||||
scalarMissing ||
|
||||
imageryError ||
|
||||
(overlay === 'none' && !speedLegendVisible)
|
||||
? []
|
||||
: legend,
|
||||
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
|
||||
infoTitle:
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
};
|
||||
controls.summary.reading = reading;
|
||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||
const setting = (id, label, prefix) => ({
|
||||
id,
|
||||
label,
|
||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||
});
|
||||
controls.summary.settings = [
|
||||
setting('model', 'MODEL', 'model-'),
|
||||
setting('field', 'FIELD', 'overlay-'),
|
||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
||||
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
|
||||
: []),
|
||||
setting('motion', 'MOTION', 'motion'),
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'read-wind',
|
||||
);
|
||||
return controls;
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
rowControlsListener = typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock?.();
|
||||
rendering?.destroy();
|
||||
rendering = null;
|
||||
imageryHost = null;
|
||||
viewer = null;
|
||||
rowControlsListener = null;
|
||||
inspectionMarker?.destroy();
|
||||
inspectionMarker = null;
|
||||
},
|
||||
getStats() {
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
const preparing =
|
||||
enabled &&
|
||||
diagnostic?.renderMode === 'gpu-streamlines' &&
|
||||
diagnostic.gpu?.pathCount > 0 &&
|
||||
!diagnostic.gpu?.ready;
|
||||
const imageryError =
|
||||
enabled && !loading
|
||||
? rendering?.getDiagnostics?.()?.imageryError
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
paused,
|
||||
stale: Boolean(manifest?.stale),
|
||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
error:
|
||||
error ||
|
||||
windStats(manifest).error ||
|
||||
(requestedScalar() !== 'none' ? manifest?.scalarError : null) ||
|
||||
imageryError,
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
return rendering?.getDiagnostics?.() || {};
|
||||
},
|
||||
getParticleCount() {
|
||||
return rendering?.getParticleCount() || 0;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
|
||||
|
||||
const snapshot = (model) => ({
|
||||
model,
|
||||
grid: { nx: 2, ny: 2 },
|
||||
cycle: { runIso: '2026-09-14T12:00:00Z', validIso: '2026-09-14T18:00:00Z' },
|
||||
});
|
||||
function harness(feed, diagnostics = {}) {
|
||||
const calls = [];
|
||||
const rendering = Object.fromEntries(
|
||||
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
|
||||
name,
|
||||
(...args) => calls.push([name, ...args]),
|
||||
]),
|
||||
);
|
||||
rendering.getDiagnostics = () => diagnostics;
|
||||
const layer = createWindLayer({ feed, createRendering: () => rendering });
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
return { layer, calls };
|
||||
}
|
||||
test('forecast valid time and source age stay distinct', () => {
|
||||
assert.equal(formatWindValidTime('invalid'), null);
|
||||
assert.equal(
|
||||
formatWindValidTime('2026-01-05T06:30:00Z'),
|
||||
'2026-01-05 06:30 UTC',
|
||||
);
|
||||
assert.equal(
|
||||
windStats(snapshot('gfs')).lastUpdate,
|
||||
Date.parse('2026-09-14T12:00:00Z'),
|
||||
);
|
||||
});
|
||||
test('switching models clears old data and ignores an abort-insensitive late source', async () => {
|
||||
const pending = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => pending.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await Promise.resolve();
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(layer.getStats().model, 'IFS');
|
||||
pending[0].resolve(snapshot('gfs'));
|
||||
await first;
|
||||
assert.equal(calls.filter(([name]) => name === 'setField').length, 0);
|
||||
pending[1].resolve(snapshot('ifs'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(calls.find(([name]) => name === 'setField')[1].model, 'ifs');
|
||||
assert.match(
|
||||
layer.getRowControls().info,
|
||||
/IFS forecast[\s\S]*Valid: 2026-09-14 18:00 UTC[\s\S]*Issued: 2026-09-14 12:00 UTC/,
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
test('external and owned cancellation both cancel source work; queued switches stop on disable', async () => {
|
||||
let signal;
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) => {
|
||||
signal = args.signal;
|
||||
return new Promise((resolve) =>
|
||||
signal.addEventListener('abort', () => resolve(snapshot('gfs')), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const external = new AbortController();
|
||||
const update = layer.update(null, { signal: external.signal });
|
||||
layer.disable();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(external.signal.aborted, false);
|
||||
assert.equal(await update, false);
|
||||
layer.enable();
|
||||
const other = layer.update(null, { signal: external.signal });
|
||||
external.abort();
|
||||
assert.equal(signal.aborted, true);
|
||||
await other;
|
||||
layer.setParams({ model: 'ifs' });
|
||||
layer.disable();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
const complete = (model, extra = {}) => ({
|
||||
...snapshot(model),
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: new Float32Array(4).fill(4),
|
||||
v: new Float32Array(4).fill(3),
|
||||
...extra,
|
||||
});
|
||||
test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => {
|
||||
const requests = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: async (args) => {
|
||||
requests.push(args);
|
||||
return complete(
|
||||
args.model,
|
||||
args.overlay === 'pressure'
|
||||
? {
|
||||
scalar: {
|
||||
kind: 'pressure',
|
||||
units: 'hPa',
|
||||
values: new Float32Array(4).fill(1013),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
);
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
layer.setParams({ overlay: 'speed', units: 'mph', paused: true });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 1, 'speed/units/pause do not download weather');
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
model: 'gfs',
|
||||
overlay: 'speed',
|
||||
units: 'mph',
|
||||
paused: true,
|
||||
});
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].overlay, 'pressure');
|
||||
assert.equal(layer.getStats().overlay, 'pressure');
|
||||
assert.ok(layer.getRowControls().info.includes('(hPa)'));
|
||||
assert.equal(layer.getRowControls().legend.at(-1).label, '1050+');
|
||||
layer.destroy();
|
||||
});
|
||||
test('local appearance changes preserve an in-flight first load', async () => {
|
||||
const requests = [];
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => requests.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].signal.aborted, false);
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
requests[0].resolve(complete('gfs'));
|
||||
await first;
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
test('re-enable and an appearance change cannot reuse a released renderer field', async () => {
|
||||
const requests = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => requests.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
requests[0].resolve(complete('gfs'));
|
||||
await first;
|
||||
layer.disable();
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
layer.enable();
|
||||
const second = layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].signal.aborted, false);
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
requests[1].resolve(complete('gfs'));
|
||||
await second;
|
||||
assert.equal(
|
||||
calls.filter(([name]) => name === 'setField').length,
|
||||
2,
|
||||
'new field must reach the cleared renderer',
|
||||
);
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
test('missing optional scalar retains valid wind and labels the field unavailable', async () => {
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () =>
|
||||
complete('gfs', {
|
||||
overlay: 'pressure',
|
||||
scalarError: 'Mean sea level pressure field unavailable',
|
||||
}),
|
||||
});
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.match(layer.getStats().error, /pressure field unavailable/);
|
||||
assert.match(layer.getRowControls().info, /Selected field unavailable/);
|
||||
assert.deepEqual(layer.getRowControls().legend, []);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('a pending companion does not claim that it is already unavailable', async () => {
|
||||
let resolve;
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) =>
|
||||
args.overlay === 'none'
|
||||
? Promise.resolve(complete('gfs'))
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
await layer.update();
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
assert.doesNotMatch(
|
||||
layer.getRowControls().info,
|
||||
/Selected field unavailable/,
|
||||
);
|
||||
resolve(
|
||||
complete('gfs', {
|
||||
scalarError: 'Mean sea level pressure field unavailable',
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.match(layer.getRowControls().info, /Selected field unavailable/);
|
||||
layer.destroy();
|
||||
});
|
||||
test('an imagery failure appears in status and cannot retain a misleading field legend', async () => {
|
||||
const diagnostics = { imageryError: null };
|
||||
const { layer } = harness(
|
||||
{ getSnapshot: async () => complete('gfs') },
|
||||
diagnostics,
|
||||
);
|
||||
await layer.update();
|
||||
diagnostics.imageryError = 'Globe field image unavailable';
|
||||
assert.equal(layer.getStats().error, 'Globe field image unavailable');
|
||||
assert.match(layer.getRowControls().info, /Globe field image unavailable/);
|
||||
assert.deepEqual(layer.getRowControls().legend, []);
|
||||
layer.destroy();
|
||||
});
|
||||
test('renderer readiness pushes fresh loading stats and controls to the displayed row', async () => {
|
||||
let ready = false;
|
||||
let statusChanged;
|
||||
const rendering = {
|
||||
attach() {},
|
||||
start() {},
|
||||
stop() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
setField() {},
|
||||
getDiagnostics: () => ({
|
||||
renderMode: 'gpu-streamlines',
|
||||
gpu: { pathCount: 10, ready },
|
||||
}),
|
||||
};
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async () => snapshot('gfs') },
|
||||
createRendering(options) {
|
||||
statusChanged = options.onStatusChange;
|
||||
return rendering;
|
||||
},
|
||||
});
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
let displayed;
|
||||
layer.setRowControlsListener(() => {
|
||||
displayed = {
|
||||
loading: layer.getStats().loading,
|
||||
info: layer.getRowControls().info,
|
||||
};
|
||||
});
|
||||
const update = layer.update();
|
||||
const loadingInfo = layer.getRowControls().info;
|
||||
assert.match(loadingInfo, /Valid: [^\n]+ · loading/);
|
||||
await update;
|
||||
assert.equal(displayed.loading, true);
|
||||
assert.match(displayed.info, /Valid: [^\n]+ · preparing/);
|
||||
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
|
||||
ready = true;
|
||||
statusChanged();
|
||||
assert.equal(displayed.loading, false);
|
||||
assert.doesNotMatch(displayed.info, / · preparing| · loading/);
|
||||
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('weather summary describes the selected forecast and count remains numeric', async () => {
|
||||
const { layer } = harness({ getSnapshot: async () => complete('gfs') });
|
||||
await layer.update();
|
||||
assert.equal(typeof layer.getStats().count, 'number');
|
||||
assert.equal(layer.getStats().countLabel, 'Forecast');
|
||||
const summary = layer.getRowControls().summary;
|
||||
assert.equal(summary.label, 'Wind motion');
|
||||
assert.equal(summary.units, 'km/h');
|
||||
assert.match(summary.detail, /GFS forecast.*UTC/);
|
||||
assert.equal((summary.detail.match(/UTC/g) || []).length, 1);
|
||||
assert.equal(layer.getRowControls().chips.find(c => c.id === 'overlay-temperature').label, 'Temperature');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
|
||||
const nodes = [];
|
||||
let listener;
|
||||
let samples = 0;
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
};
|
||||
const viewer = {
|
||||
container,
|
||||
camera: { positionWC: {}, pickEllipsoid: () => { samples++; return { longitude: 0, latitude: 0 }; } },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 0, top: 0 }) },
|
||||
cartesianToCanvasCoordinates: () => ({ x: 400, y: 300 }),
|
||||
postRender: { addEventListener(fn) { listener = fn; return () => { listener = null; }; } },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const rendering = Object.fromEntries(['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map(name => [name, () => {}]));
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async ({ model }) => complete(model) },
|
||||
cesium: {
|
||||
Cartesian2: class {}, Ellipsoid: { WGS84: {} }, SceneMode: { SCENE3D: 3 },
|
||||
Cartographic: { fromCartesian: point => point }, Math: { toDegrees: value => value },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return true; } },
|
||||
},
|
||||
createRendering: () => rendering,
|
||||
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ inspect: true });
|
||||
const captured = layer.getRowControls().summary.reading;
|
||||
assert.equal(samples, 1);
|
||||
const retained = nodes[0];
|
||||
layer.setParams({ units: 'mph' });
|
||||
assert.equal(nodes[0], retained);
|
||||
const changed = layer.getRowControls().summary.reading;
|
||||
assert.equal(changed.speed, captured.speed);
|
||||
assert.equal(changed.coordinates, captured.coordinates);
|
||||
assert.match(changed.wind, /mph/);
|
||||
assert.equal(samples, 1, 'units do not resample');
|
||||
assert.equal(layer.getRowControls().summary.result.id, 'reading');
|
||||
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(typeof listener, 'function');
|
||||
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
|
||||
layer.setParams({ overlay: 'speed' });
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const resampled = layer.getRowControls().summary.reading;
|
||||
assert.equal(resampled.coordinates, captured.coordinates);
|
||||
assert.equal(resampled.model, 'ECMWF');
|
||||
assert.equal(resampled.position, captured.position);
|
||||
assert.equal(samples, 1, 'model and field changes never sample the moved camera');
|
||||
assert.match(layer.getRowControls().summary.result.lines.find(({ id }) => id === 'meta').text, /ECMWF · valid/);
|
||||
await layer.update();
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, '20.00°N · 70.00°E');
|
||||
assert.equal(samples, 2, 'the next explicit read samples the new center');
|
||||
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(nodes.length, 1);
|
||||
change();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(layer.getRowControls().summary.reading, null);
|
||||
assert.equal(layer.getRowControls().summary.result, null);
|
||||
assert.equal(listener, null);
|
||||
}
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('observed history labels wind as a forecast without changing its data or parameters', async () => {
|
||||
const { createWeatherClock } = await import('../weather/clock.js');
|
||||
const clock = createWeatherClock();
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
|
||||
let changes = 0;
|
||||
layer.setRowControlsListener(() => changes++);
|
||||
const params = layer.getParams();
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status');
|
||||
assert.ok(changes > 0);
|
||||
assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
|
||||
assert.deepEqual(layer.getParams(), params);
|
||||
await clock.latest();
|
||||
assert.equal(layer.getRowControls().summary.status, null);
|
||||
layer.destroy();
|
||||
const before = changes;
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
assert.equal(changes, before);
|
||||
clock.destroy();
|
||||
});
|
||||
|
||||
test('wind unit chips appear only alongside a speed legend, including canvas trails', async () => {
|
||||
let renderMode = 'gpu-streamlines'; let imageryError = null;
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => complete('gfs') }, createRendering: () => ({ attach() {}, start() {}, clear() {}, setField() {}, setOptions() {}, getDiagnostics: () => ({ renderMode, imageryError }), stop() {}, destroy() {} }) });
|
||||
layer.init({ container: {} }); layer.enable(); await layer.update();
|
||||
const unitChips = () => layer.getRowControls().chips.filter(({ id }) => id.startsWith('units-'));
|
||||
assert.equal(layer.getRowControls().readout, true); assert.equal(layer.getRowControls().summary.coverage, 'Global · 1° grid');
|
||||
assert.deepEqual(unitChips(), []); assert.deepEqual(layer.getRowControls().legend, []);
|
||||
renderMode = 'canvas-fallback'; assert.equal(unitChips().length, 3); assert.ok(layer.getRowControls().legend.length);
|
||||
renderMode = 'gpu-streamlines'; layer.setParams({ overlay: 'speed' }); assert.equal(unitChips().length, 3);
|
||||
imageryError = 'Unavailable'; assert.deepEqual(unitChips(), []); imageryError = null;
|
||||
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
|
||||
layer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { sampleWind, windSpeed } from './model.js';
|
||||
import { sampleScalar } from './fields.js';
|
||||
|
||||
const DIRECTIONS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
];
|
||||
export const WIND_UNITS = Object.freeze({
|
||||
'km/h': 3.6,
|
||||
'm/s': 1,
|
||||
mph: 2.2369362921,
|
||||
});
|
||||
|
||||
/** Meteorological bearing is where the wind comes FROM, not where it travels. */
|
||||
export function windFrom(u, v) {
|
||||
if (Math.hypot(u, v) < 0.2) return 'Calm';
|
||||
const bearing = ((Math.atan2(-u, -v) * 180) / Math.PI + 360) % 360;
|
||||
return DIRECTIONS[Math.round(bearing / 22.5) % 16];
|
||||
}
|
||||
|
||||
export function formatWindSpeed(speed, units = 'km/h') {
|
||||
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
|
||||
return `${(speed * WIND_UNITS[unit]).toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
/** A deliberate snapshot at the map center; does not own pointer input. */
|
||||
export function inspectWindAtCenter(
|
||||
snapshot,
|
||||
viewer,
|
||||
cesium,
|
||||
{ units, overlay, model, validTime, status, position } = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const canvas = scene?.canvas;
|
||||
const ellipsoid = scene?.globe?.ellipsoid || cesium.Ellipsoid?.WGS84;
|
||||
const point =
|
||||
position ||
|
||||
(canvas &&
|
||||
viewer.camera?.pickEllipsoid?.(
|
||||
new cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
ellipsoid,
|
||||
));
|
||||
if (!point || !snapshot?.u || !snapshot?.v)
|
||||
return {
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Aim the map center at Earth',
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
||||
};
|
||||
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
||||
const lon = cesium.Math.toDegrees(location.longitude);
|
||||
const lat = cesium.Math.toDegrees(location.latitude);
|
||||
const vector = sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const speed = windSpeed(vector.u, vector.v);
|
||||
const from = windFrom(vector.u, vector.v);
|
||||
const scalar = ['temperature', 'pressure'].includes(overlay)
|
||||
? sampleScalar(snapshot, lon, lat, overlay)
|
||||
: null;
|
||||
return {
|
||||
position: point,
|
||||
speed,
|
||||
from,
|
||||
units,
|
||||
coordinates: `${Math.abs(lat).toFixed(2)}°${lat < 0 ? 'S' : 'N'} · ${Math.abs(lon).toFixed(2)}°${lon < 0 ? 'W' : 'E'}`,
|
||||
wind: `${formatWindSpeed(speed, units)}${from === 'Calm' ? ' · calm' : ` from ${from}`}`,
|
||||
scalarKind: overlay,
|
||||
scalarLabel:
|
||||
overlay === 'speed'
|
||||
? 'Wind speed'
|
||||
: overlay === 'temperature'
|
||||
? 'Air temperature · 2 m'
|
||||
: overlay === 'pressure'
|
||||
? 'Sea-level pressure'
|
||||
: null,
|
||||
scalarValue:
|
||||
overlay === 'speed'
|
||||
? formatWindSpeed(speed, units)
|
||||
: scalar == null
|
||||
? null
|
||||
: `${scalar.toFixed(1)} ${overlay === 'temperature' ? '°C' : 'hPa'}`,
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||
};
|
||||
}
|
||||
|
||||
/** One passive marker for the captured location; it never samples on camera movement. */
|
||||
export function createWindInspectionMarker({ container, viewer, cesium }) {
|
||||
let removeRender = null;
|
||||
let marker = null;
|
||||
const clear = () => {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
marker?.remove();
|
||||
marker = null;
|
||||
viewer?.scene?.requestRender?.();
|
||||
};
|
||||
return {
|
||||
show(position) {
|
||||
clear();
|
||||
const scene = viewer?.scene;
|
||||
if (
|
||||
!position ||
|
||||
!container?.ownerDocument?.createElement ||
|
||||
!scene?.postRender?.addEventListener ||
|
||||
!scene.cartesianToCanvasCoordinates
|
||||
)
|
||||
return;
|
||||
marker = container.ownerDocument.createElement('div');
|
||||
marker.className = 'gev-wind-inspection-marker';
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
marker.style.cssText =
|
||||
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
|
||||
container.appendChild(marker);
|
||||
const occluder = new cesium.EllipsoidalOccluder(
|
||||
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const render = () => {
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = scene.cartesianToCanvasCoordinates(position);
|
||||
const visible =
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= scene.canvas.clientWidth &&
|
||||
point.y <= scene.canvas.clientHeight &&
|
||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
||||
occluder.isPointVisible(position));
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
|
||||
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
|
||||
};
|
||||
removeRender = scene.postRender.addEventListener(render);
|
||||
render();
|
||||
scene.requestRender?.();
|
||||
},
|
||||
clear,
|
||||
destroy: clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
windFrom,
|
||||
formatWindSpeed,
|
||||
inspectWindAtCenter,
|
||||
} from './inspection.js';
|
||||
|
||||
test('wind directions describe the source of the flow and calm is not north', () => {
|
||||
assert.equal(windFrom(10, 0), 'W');
|
||||
assert.equal(windFrom(-10, 0), 'E');
|
||||
assert.equal(windFrom(0, 10), 'S');
|
||||
assert.equal(windFrom(0, -10), 'N');
|
||||
assert.equal(windFrom(0, 0), 'Calm');
|
||||
assert.equal(formatWindSpeed(10, 'km/h'), '36.0 km/h');
|
||||
assert.equal(formatWindSpeed(10, 'mph'), '22.4 mph');
|
||||
});
|
||||
test('center inspection uses a fixed forecast reading without double-converting scalar units', () => {
|
||||
const snapshot = {
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: Float32Array.from([10, 10, 10, 10]),
|
||||
v: new Float32Array(4),
|
||||
scalar: {
|
||||
kind: 'temperature',
|
||||
units: '°C',
|
||||
values: Float32Array.from([20, 20, 20, 20]),
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
camera: { pickEllipsoid: () => ({ longitude: 0, latitude: 0 }) },
|
||||
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
|
||||
};
|
||||
const cesium = {
|
||||
Cartesian2: class {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
},
|
||||
Ellipsoid: { WGS84: {} },
|
||||
Cartographic: { fromCartesian: (p) => p },
|
||||
Math: { toDegrees: (x) => (x * 180) / Math.PI },
|
||||
};
|
||||
const result = inspectWindAtCenter(snapshot, viewer, cesium, {
|
||||
overlay: 'temperature',
|
||||
units: 'km/h',
|
||||
model: 'GFS',
|
||||
validTime: 'fixed',
|
||||
status: 'forecast',
|
||||
});
|
||||
assert.equal(result.wind, '36.0 km/h from W');
|
||||
assert.equal(result.scalarValue, '20.0 °C');
|
||||
assert.equal(result.validTime, 'fixed');
|
||||
assert.equal(result.coordinates, '0.00°N · 0.00°E');
|
||||
viewer.camera.pickEllipsoid = () => null;
|
||||
assert.equal(
|
||||
inspectWindAtCenter(snapshot, viewer, cesium).coordinates,
|
||||
'No surface reading',
|
||||
);
|
||||
});
|
||||
|
||||
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
|
||||
const { createWindInspectionMarker } = await import('./inspection.js');
|
||||
let listener = null;
|
||||
let visible = true;
|
||||
const projected = [];
|
||||
const nodes = [];
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 5, top: 10 }),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { positionWC: {} },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
|
||||
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
|
||||
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const cesium = {
|
||||
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
|
||||
};
|
||||
const owner = createWindInspectionMarker({ container, viewer, cesium });
|
||||
const point = { x: 1, y: 2, z: 3 };
|
||||
owner.show(point);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.match(nodes[0].style.cssText, /pointer-events:none/);
|
||||
assert.equal(nodes[0].style.left, '110px');
|
||||
assert.equal(nodes[0].style.top, '220px');
|
||||
viewer.camera.positionWC = { moved: true };
|
||||
visible = false;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, true);
|
||||
assert.ok(projected.every((value) => value === point));
|
||||
visible = true;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, false);
|
||||
owner.show({ x: 4 });
|
||||
assert.equal(nodes.length, 1);
|
||||
owner.clear();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
owner.show(point);
|
||||
owner.destroy();
|
||||
owner.destroy();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Normalize a longitude into the half-open world interval. */
|
||||
export function normalizeLongitude(lon) {
|
||||
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
function interpolate(array, a, b, c, d, tx, ty) {
|
||||
return (
|
||||
(array[a] * (1 - tx) + array[b] * tx) * (1 - ty) +
|
||||
(array[c] * (1 - tx) + array[d] * tx) * ty
|
||||
);
|
||||
}
|
||||
|
||||
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
||||
export function sampleWind(field, lon, lat, result = {}) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||
result.u = 0;
|
||||
result.v = 0;
|
||||
return result;
|
||||
}
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = (la1 - lat) / dy;
|
||||
const x0 = Math.floor(x);
|
||||
const tx = x - x0;
|
||||
const yClamped = Math.max(0, Math.min(ny - 1, y));
|
||||
const y0 = Math.floor(yClamped);
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const ty = yClamped - y0;
|
||||
const left = ((x0 % nx) + nx) % nx;
|
||||
const right = (left + 1) % nx;
|
||||
const a = y0 * nx + left;
|
||||
const b = y0 * nx + right;
|
||||
const c = y1 * nx + left;
|
||||
const d = y1 * nx + right;
|
||||
result.u = interpolate(u, a, b, c, d, tx, ty);
|
||||
result.v = interpolate(v, a, b, c, d, tx, ty);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return metres represented by one longitude degree at a latitude. */
|
||||
export function metersPerDegreeLon(lat) {
|
||||
return 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** Calculate wind magnitude. */
|
||||
export function windSpeed(u, v) {
|
||||
return Math.hypot(u, v);
|
||||
}
|
||||
|
||||
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
|
||||
export function windColor(speed, { max = 30 } = {}) {
|
||||
const stops = [
|
||||
'#1e3a8a',
|
||||
'#2563eb',
|
||||
'#22d3ee',
|
||||
'#34d399',
|
||||
'#fbbf24',
|
||||
'#f97316',
|
||||
'#ef4444',
|
||||
];
|
||||
const t = Math.max(0, Math.min(1, speed / max));
|
||||
const position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
const fraction = position - index;
|
||||
if (t === 1) return stops.at(-1);
|
||||
const parse = (color) =>
|
||||
color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
||||
const a = parse(stops[index]);
|
||||
const b = parse(stops[index + 1]);
|
||||
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
|
||||
}
|
||||
|
||||
/** Advance a particle by a wind vector, mutating and returning it. */
|
||||
export function advectParticle(
|
||||
particle,
|
||||
wind,
|
||||
dtSeconds,
|
||||
{ speedScale = 1 } = {},
|
||||
) {
|
||||
particle.lon = normalizeLongitude(
|
||||
particle.lon +
|
||||
(wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
||||
);
|
||||
particle.lat = Math.max(
|
||||
-89,
|
||||
Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320),
|
||||
);
|
||||
return particle;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { advectParticle, sampleWind, windColor } from './model.js';
|
||||
|
||||
const field = {
|
||||
u: Float32Array.from([1, 2, 3, 4]),
|
||||
v: Float32Array.from([5, 6, 7, 8]),
|
||||
nx: 2,
|
||||
ny: 2,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
dx: 180,
|
||||
dy: 90,
|
||||
};
|
||||
|
||||
test('sampleWind interpolates, wraps, clamps, and rejects non-finite input', () => {
|
||||
assert.deepEqual(sampleWind(field, 0, 90), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, 90, 45), { u: 2.5, v: 6.5 });
|
||||
assert.deepEqual(sampleWind(field, 359.99, 90), sampleWind(field, -0.01, 90));
|
||||
assert.deepEqual(sampleWind(field, 0, 100), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, Infinity, 0), { u: 0, v: 0 });
|
||||
});
|
||||
|
||||
test('advectParticle moves and constrains particles', () => {
|
||||
const particle = { lon: 179, lat: 88 };
|
||||
advectParticle(particle, { u: 10, v: 100 }, 3600);
|
||||
assert.ok(particle.lon < 0);
|
||||
assert.equal(particle.lat, 89);
|
||||
});
|
||||
|
||||
test('windColor returns a monotonic ramp string', () => {
|
||||
assert.equal(typeof windColor(0), 'string');
|
||||
assert.notEqual(windColor(0), windColor(30));
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
if (!reading) return null;
|
||||
return {
|
||||
...reading,
|
||||
units,
|
||||
scalarValue:
|
||||
reading.scalarKind === 'speed'
|
||||
? formatWindSpeed(reading.speed, units)
|
||||
: reading.scalarValue,
|
||||
wind: Number.isFinite(reading.speed)
|
||||
? `${formatWindSpeed(reading.speed, units)}${reading.from === 'Calm' ? ' · calm' : ` from ${reading.from}`}`
|
||||
: reading.wind,
|
||||
};
|
||||
}
|
||||
|
||||
export function windUnitChips(units) {
|
||||
return Object.keys(WIND_UNITS).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
return {
|
||||
id: 'reading',
|
||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
||||
lines: [
|
||||
{
|
||||
id: 'wind',
|
||||
text: reading.wind,
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatWindReading, windReadingResult } from './presentation.js';
|
||||
|
||||
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
|
||||
test('captured reading reformats without changing its sample and produces a portable result block', () => {
|
||||
const reading = formatWindReading(sample, 'mph');
|
||||
assert.equal(reading.wind, '11.2 mph from SW');
|
||||
assert.equal(reading.speed, sample.speed);
|
||||
assert.equal(reading.coordinates, sample.coordinates);
|
||||
assert.equal(sample.units, undefined);
|
||||
const section = windReadingResult(reading);
|
||||
assert.equal(section.id, 'reading');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
|
||||
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
|
||||
assert.deepEqual(section.clear.params, { inspect: false });
|
||||
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
|
||||
assert.equal(formatWindReading(null, 'km/h'), null);
|
||||
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// View-directed display lighting over existing terrain normals. This does not
|
||||
// represent the Sun, change terrain geometry, or supply missing elevation data.
|
||||
function reliefSource(terrainNormals) {
|
||||
const normal = terrainNormals
|
||||
? 'vec3 normal = normalize(materialInput.normalEC);'
|
||||
: `vec3 worldPosition = (czm_inverseView * vec4(-materialInput.positionToEyeEC, 1.0)).xyz;
|
||||
vec3 geodeticNormal = normalize(worldPosition * czm_ellipsoidInverseRadii * czm_ellipsoidInverseRadii);
|
||||
vec3 normal = normalize(czm_viewRotation * geodeticNormal);`;
|
||||
return `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
${normal}
|
||||
vec3 displayLight = normalize(vec3(-0.45, 0.55, 0.8));
|
||||
float shade = clamp((1.0 - dot(normal, displayLight)) * 0.32, 0.0, 0.28);
|
||||
material.diffuse = vec3(0.0);
|
||||
material.alpha = shade;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Optional, exclusive globe-material owner; no animation or source acquisition. */
|
||||
export function createWindRelief({ cesium, getViewer } = {}) {
|
||||
let globe = null;
|
||||
let scene = null;
|
||||
let owned = null;
|
||||
let previous = undefined;
|
||||
let destroyed = false;
|
||||
let reason = 'Not attached';
|
||||
let mode = null;
|
||||
const alive = (value) => value && !value.isDestroyed?.();
|
||||
const active = () =>
|
||||
Boolean(owned && alive(globe) && globe.material === owned);
|
||||
|
||||
function clear() {
|
||||
const changed = active();
|
||||
if (changed) globe.material = previous;
|
||||
if (owned && !owned.isDestroyed?.()) owned.destroy?.();
|
||||
if (changed && alive(scene)) scene.requestRender?.();
|
||||
owned = null;
|
||||
mode = null;
|
||||
globe = null;
|
||||
scene = null;
|
||||
previous = undefined;
|
||||
reason = destroyed ? 'Destroyed' : 'Not attached';
|
||||
}
|
||||
|
||||
return {
|
||||
attach() {
|
||||
if (destroyed) return false;
|
||||
const viewer = getViewer?.();
|
||||
const nextScene = alive(viewer) ? viewer.scene : null;
|
||||
const nextGlobe = nextScene?.globe;
|
||||
const nextMode =
|
||||
nextGlobe?.terrainProvider?.hasVertexNormals === true
|
||||
? 'terrain relief'
|
||||
: 'globe curvature';
|
||||
if (active() && globe === nextGlobe && mode === nextMode) return true;
|
||||
clear();
|
||||
if (!alive(nextGlobe) || typeof cesium?.Material !== 'function') {
|
||||
reason = 'Globe material unavailable';
|
||||
return false;
|
||||
}
|
||||
if (nextGlobe.material != null) {
|
||||
reason = 'Globe material already owned';
|
||||
return false;
|
||||
}
|
||||
// Cesium excludes normal-dependent materials without terrain normals.
|
||||
// The fallback uses existing position/ellipsoid uniforms: curvature only,
|
||||
// never an invented terrain normal or an additional provider request.
|
||||
try {
|
||||
owned = new cesium.Material({
|
||||
fabric: {
|
||||
type:
|
||||
nextMode === 'terrain relief'
|
||||
? 'GEVWindTerrainRelief'
|
||||
: 'GEVWindGlobeCurvature',
|
||||
source: reliefSource(nextMode === 'terrain relief'),
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
mode = nextMode;
|
||||
globe = nextGlobe;
|
||||
scene = nextScene;
|
||||
previous = globe.material;
|
||||
globe.material = owned;
|
||||
scene.requestRender?.();
|
||||
reason = null;
|
||||
return true;
|
||||
} catch {
|
||||
clear();
|
||||
reason = 'Relief material unavailable';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clear();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
active: active(),
|
||||
reason: owned && !active() ? 'Globe material replaced' : reason,
|
||||
lighting: 'View-directed display shading',
|
||||
mode: active() ? mode : null,
|
||||
maxOpacity: 0.28,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindRelief } from './relief.js';
|
||||
|
||||
function fixture({ material = undefined, normals = true } = {}) {
|
||||
const materials = [];
|
||||
class Material {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.destroyed = false;
|
||||
materials.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
assert.equal(this.destroyed, false);
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
let renders = 0;
|
||||
const globe = { material, terrainProvider: { hasVertexNormals: normals } };
|
||||
const scene = { globe, requestRender: () => renders++ };
|
||||
const viewer = { scene };
|
||||
const relief = createWindRelief({
|
||||
cesium: { Material },
|
||||
getViewer: () => viewer,
|
||||
});
|
||||
return { relief, globe, scene, viewer, materials, renders: () => renders };
|
||||
}
|
||||
|
||||
test('relief acquires once and restores the exact prior empty material on clear', () => {
|
||||
for (const material of [null, undefined]) {
|
||||
const f = fixture({ material });
|
||||
f.globe.material = material;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.materials.length, 1);
|
||||
assert.equal(f.globe.material, f.materials[0]);
|
||||
assert.equal(f.relief.getDiagnostics().active, true);
|
||||
f.relief.clear();
|
||||
assert.equal(f.globe.material, material);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.renders(), 2);
|
||||
f.relief.clear();
|
||||
assert.equal(f.renders(), 2);
|
||||
assert.equal(f.relief.attach(), true);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.materials[1].destroyed, true);
|
||||
assert.equal(f.relief.attach(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('pre-existing and replacement material owners are never overwritten', () => {
|
||||
const other = { name: 'other-owner' };
|
||||
const f = fixture({ material: other });
|
||||
assert.equal(f.relief.attach(), false);
|
||||
assert.equal(f.globe.material, other);
|
||||
assert.equal(f.materials.length, 0);
|
||||
f.globe.material = undefined;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
f.globe.material = other;
|
||||
assert.equal(f.relief.getDiagnostics().reason, 'Globe material replaced');
|
||||
f.relief.clear();
|
||||
assert.equal(f.globe.material, other);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.relief.attach(), false);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.globe.material, other);
|
||||
});
|
||||
|
||||
test('missing terrain normals uses curvature only and upgrades when normals exist', () => {
|
||||
const f = fixture({ normals: false });
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.relief.getDiagnostics().mode, 'globe curvature');
|
||||
const source = f.materials[0].options.fabric.source;
|
||||
assert.doesNotMatch(source, /normalEC|slope/);
|
||||
assert.match(source, /materialInput.positionToEyeEC/);
|
||||
assert.match(source, /czm_ellipsoidInverseRadii/);
|
||||
f.globe.terrainProvider.hasVertexNormals = true;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.relief.getDiagnostics().mode, 'terrain relief');
|
||||
assert.match(f.materials[1].options.fabric.source, /materialInput.normalEC/);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.globe.material, undefined);
|
||||
});
|
||||
|
||||
test('moving to a new viewer releases the old globe and owns only the new one', () => {
|
||||
const f = fixture();
|
||||
f.relief.attach();
|
||||
const old = f.globe;
|
||||
const next = { material: null, terrainProvider: { hasVertexNormals: true } };
|
||||
f.viewer.scene = { globe: next, requestRender() {} };
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(old.material, undefined);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(next.material, f.materials[1]);
|
||||
f.relief.destroy();
|
||||
assert.equal(next.material, null);
|
||||
});
|
||||
@@ -0,0 +1,801 @@
|
||||
import {
|
||||
createShellSurface,
|
||||
WEATHER_SHELL_HEIGHTS,
|
||||
} from '../weather/shellRendering.js';
|
||||
import { createWindRelief } from './relief.js';
|
||||
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import { advectParticle, sampleWind } from './model.js';
|
||||
import {
|
||||
createFieldRaster,
|
||||
trailEraseAlpha,
|
||||
windTrailColor,
|
||||
} from './fields.js';
|
||||
|
||||
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
|
||||
const WIND_FIELD_FADE_LOW_METERS = 200_000;
|
||||
const WIND_FIELD_LOG_RANGE = Math.log(
|
||||
WIND_FIELD_FADE_HIGH_METERS / WIND_FIELD_FADE_LOW_METERS,
|
||||
);
|
||||
|
||||
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
|
||||
// (which include the scalar overlay) or only cycle IDs (which can be revised).
|
||||
// This bounded scan runs on acquisition, never in the animation loop.
|
||||
function sameWind(previous, next, previousField, nextField) {
|
||||
if (!previous || !next || !previousField || !nextField) return false;
|
||||
for (const key of ['model', 'level', 'units'])
|
||||
if (previous[key] !== next[key]) return false;
|
||||
for (const key of ['runIso', 'validIso', 'forecastHour', 'date', 'hour'])
|
||||
if (previous.cycle?.[key] !== next.cycle?.[key]) return false;
|
||||
for (const key of ['nx', 'ny', 'lo1', 'la1', 'dx', 'dy'])
|
||||
if (previousField[key] !== nextField[key]) return false;
|
||||
for (const key of ['u', 'v']) {
|
||||
const before = previousField[key];
|
||||
const after = nextField[key];
|
||||
if (!before || !after || before.length !== after.length) return false;
|
||||
if (before === after) continue;
|
||||
for (let i = 0; i < before.length; i++)
|
||||
if (before[i] !== after[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Globe-projected surface flow plus one owned, static colour field: globe
|
||||
* imagery on the globe host, a raised shell on the 3D Tiles host. */
|
||||
export function createWindRendering({
|
||||
cesium,
|
||||
container,
|
||||
getViewer,
|
||||
getHost = () => ({
|
||||
collection:
|
||||
getViewer?.()?.imageryLayers ?? getViewer?.()?.scene?.imageryLayers,
|
||||
kind: 'globe',
|
||||
}),
|
||||
eventTarget = globalThis.window,
|
||||
createGpuRendering = createWindGpuRendering,
|
||||
onStatusChange,
|
||||
} = {}) {
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
let snapshot = null;
|
||||
let field = null;
|
||||
let particles = [];
|
||||
let frame = null;
|
||||
let running = false;
|
||||
let lastTime = null;
|
||||
let cssWidth = 1;
|
||||
let cssHeight = 1;
|
||||
let warned = false;
|
||||
let overlay = 'none';
|
||||
let paused = false;
|
||||
let reducedMotion = false;
|
||||
let imagery = null;
|
||||
let shell = null;
|
||||
let imageryCollection = null;
|
||||
let imageryKind = null;
|
||||
let imageryError = null;
|
||||
let imageryErrorRemove = null;
|
||||
let media = null;
|
||||
let removers = [];
|
||||
let bounds = null;
|
||||
let cameraChanged = true;
|
||||
let painted = 0;
|
||||
const gpu = createGpuRendering({ cesium, getViewer });
|
||||
let gpuActive = false;
|
||||
let gpuVisible = false;
|
||||
let reportedGpuReady = null;
|
||||
let gpuNarrow = false;
|
||||
let flowTime = 0;
|
||||
const relief = createWindRelief({ cesium, getViewer });
|
||||
const signature = Array(8).fill(null);
|
||||
const wind = { u: 0, v: 0 };
|
||||
const before = { lon: 0, lat: 0 };
|
||||
const world =
|
||||
typeof cesium.Cartesian3 === 'function' ? new cesium.Cartesian3() : {};
|
||||
const screenA =
|
||||
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
|
||||
const screenB =
|
||||
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
|
||||
const degree = Math.PI / 180;
|
||||
const hidden = () => globalThis.document?.hidden === true;
|
||||
const still = () => paused || reducedMotion;
|
||||
const budget = () =>
|
||||
Math.max(
|
||||
200,
|
||||
Math.min(
|
||||
cssWidth < 600 ? 1000 : 3000,
|
||||
Math.floor((cssWidth * cssHeight) / 420),
|
||||
),
|
||||
);
|
||||
const viewerReady = () => {
|
||||
const viewer = getViewer?.();
|
||||
return viewer && !viewer.isDestroyed?.() ? viewer : null;
|
||||
};
|
||||
|
||||
function cancel() {
|
||||
if (frame !== null) globalThis.cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
function schedule() {
|
||||
if (
|
||||
running &&
|
||||
field &&
|
||||
canvas &&
|
||||
!hidden() &&
|
||||
!still() &&
|
||||
(!gpuActive || gpuVisible) &&
|
||||
frame === null
|
||||
)
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
}
|
||||
function clearPixels() {
|
||||
context?.clearRect(0, 0, cssWidth, cssHeight);
|
||||
}
|
||||
|
||||
function resize(viewer) {
|
||||
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
|
||||
cssWidth =
|
||||
viewer?.scene?.canvas?.clientWidth || container?.clientWidth || 1;
|
||||
cssHeight =
|
||||
viewer?.scene?.canvas?.clientHeight || container?.clientHeight || 1;
|
||||
const width = Math.max(1, Math.floor(cssWidth * ratio));
|
||||
const height = Math.max(1, Math.floor(cssHeight * ratio));
|
||||
// Assigning even unchanged dimensions erases a real canvas.
|
||||
if (canvas.width === width && canvas.height === height) return false;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkCamera(scene) {
|
||||
const camera = scene.camera;
|
||||
const position = camera?.positionWC;
|
||||
const values = [
|
||||
position?.x,
|
||||
position?.y,
|
||||
position?.z,
|
||||
camera?.heading,
|
||||
camera?.pitch,
|
||||
camera?.roll,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
];
|
||||
let changed = false;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i] !== signature[i]) changed = true;
|
||||
signature[i] = values[i];
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateBounds(scene) {
|
||||
bounds = null;
|
||||
if (!scene.camera?.computeViewRectangle || !cesium.Ellipsoid) return;
|
||||
try {
|
||||
const rect = scene.camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
|
||||
if (!rect) return;
|
||||
const west = rect.west / degree;
|
||||
let east = rect.east / degree;
|
||||
const south = Math.max(-89, rect.south / degree);
|
||||
const north = Math.min(89, rect.north / degree);
|
||||
if (east < west) east += 360;
|
||||
if (
|
||||
[west, east, south, north].every(Number.isFinite) &&
|
||||
east > west &&
|
||||
north > south
|
||||
)
|
||||
bounds = {
|
||||
west,
|
||||
east,
|
||||
sinSouth: Math.sin(south * degree),
|
||||
sinNorth: Math.sin(north * degree),
|
||||
};
|
||||
} catch {
|
||||
/* A sky-facing camera can have no earth rectangle. */
|
||||
}
|
||||
}
|
||||
|
||||
function makeOccluder(scene) {
|
||||
if (
|
||||
!cesium.EllipsoidalOccluder ||
|
||||
!cesium.Ellipsoid ||
|
||||
!scene.camera?.positionWC
|
||||
)
|
||||
return null;
|
||||
return new cesium.EllipsoidalOccluder(
|
||||
cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
}
|
||||
|
||||
function project(scene, occluder, lon, lat, result) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
const point = cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
0,
|
||||
cesium.Ellipsoid?.WGS84,
|
||||
world,
|
||||
);
|
||||
if (occluder && !occluder.isPointVisible(point)) return null;
|
||||
const projected = cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
point,
|
||||
result,
|
||||
);
|
||||
if (
|
||||
!projected ||
|
||||
!Number.isFinite(projected.x) ||
|
||||
!Number.isFinite(projected.y)
|
||||
)
|
||||
return null;
|
||||
// A perspective surface normal dims the limb instead of accumulating a bright rim.
|
||||
const camera = scene.camera?.positionWC;
|
||||
const dx = camera?.x - point.x;
|
||||
const dy = camera?.y - point.y;
|
||||
const dz = camera?.z - point.z;
|
||||
const denominator =
|
||||
Math.hypot(point.x, point.y, point.z) * Math.hypot(dx, dy, dz);
|
||||
const cosine =
|
||||
denominator > 0
|
||||
? (point.x * dx + point.y * dy + point.z * dz) / denominator
|
||||
: 1;
|
||||
projected.visibility = Number.isFinite(cosine)
|
||||
? Math.max(0, Math.min(1, cosine / 0.3))
|
||||
: 1;
|
||||
return projected;
|
||||
}
|
||||
const inView = (point) =>
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= cssWidth &&
|
||||
point.y <= cssHeight;
|
||||
|
||||
function spawn(particle, scene, occluder) {
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const west = bounds?.west ?? -180;
|
||||
const east = bounds?.east ?? 180;
|
||||
const low = bounds?.sinSouth ?? -0.9998;
|
||||
const high = bounds?.sinNorth ?? 0.9998;
|
||||
particle.lon = ((west + Math.random() * (east - west) + 540) % 360) - 180;
|
||||
particle.lat = Math.asin(low + Math.random() * (high - low)) / degree;
|
||||
particle.age = 1 + Math.random() * 7;
|
||||
particle.life = 8 + Math.random() * 4;
|
||||
if (inView(project(scene, occluder, particle.lon, particle.lat, screenA)))
|
||||
return;
|
||||
}
|
||||
}
|
||||
function seed(scene, occluder) {
|
||||
const count = budget();
|
||||
if (particles.length > count) particles.length = count;
|
||||
while (particles.length < count)
|
||||
particles.push({ lon: 0, lat: 0, age: 0, life: 10 });
|
||||
updateBounds(scene);
|
||||
for (const particle of particles) spawn(particle, scene, occluder);
|
||||
}
|
||||
|
||||
function removeImagery() {
|
||||
imageryErrorRemove?.();
|
||||
imageryErrorRemove = null;
|
||||
if (imagery && imageryCollection && !imageryCollection.isDestroyed?.())
|
||||
imageryCollection.remove(imagery, true);
|
||||
else if (imagery && !imagery.isDestroyed?.()) imagery.destroy?.();
|
||||
imagery = null;
|
||||
shell?.destroy();
|
||||
shell = null;
|
||||
imageryCollection = null;
|
||||
imageryKind = null;
|
||||
}
|
||||
function updateImageryFade(camera, settled = false) {
|
||||
// The tileset shell keeps the quantised move-end fade; globe imagery keeps
|
||||
// its smooth per-frame fade.
|
||||
if (
|
||||
(!imagery && !shell) ||
|
||||
!camera?.positionCartographic ||
|
||||
(imageryKind === 'tileset' && !settled)
|
||||
)
|
||||
return;
|
||||
const height = camera.positionCartographic.height;
|
||||
const fade = !gpuActive
|
||||
? 1
|
||||
: height <= WIND_FIELD_FADE_LOW_METERS
|
||||
? 0
|
||||
: height >= WIND_FIELD_FADE_HIGH_METERS
|
||||
? 1
|
||||
: Math.log(height / WIND_FIELD_FADE_LOW_METERS) /
|
||||
WIND_FIELD_LOG_RANGE;
|
||||
const baseAlpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
const smoothAlpha = baseAlpha * fade;
|
||||
const alpha =
|
||||
gpuActive && imageryKind === 'tileset'
|
||||
? Math.round(smoothAlpha * 10) / 10
|
||||
: smoothAlpha;
|
||||
if (shell) {
|
||||
shell.setAlpha(alpha);
|
||||
shell.setShow(alpha > 0 && getHost().kind !== 'none');
|
||||
return;
|
||||
}
|
||||
if (Math.abs(imagery.alpha - alpha) > 0.005) imagery.alpha = alpha;
|
||||
const show = alpha > 0;
|
||||
if (imagery.show !== show) imagery.show = show;
|
||||
}
|
||||
function installImagery() {
|
||||
removeImagery();
|
||||
imageryError = null;
|
||||
const viewer = viewerReady();
|
||||
if (!snapshot || overlay === 'none' || !viewer) return;
|
||||
const { collection, kind } = getHost();
|
||||
if (kind === 'none') {
|
||||
imageryError = NO_IMAGERY_HOST;
|
||||
return;
|
||||
}
|
||||
const raster = createFieldRaster(snapshot, overlay);
|
||||
if (!raster) {
|
||||
imageryError = `${overlay} field unavailable`;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
kind === 'tileset'
|
||||
? !cesium.Primitive
|
||||
: !collection || !cesium.SingleTileImageryProvider
|
||||
) {
|
||||
imageryError = 'Globe imagery unavailable';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const texture = document.createElement('canvas');
|
||||
texture.width = raster.width;
|
||||
texture.height = raster.height;
|
||||
const ctx = texture.getContext('2d');
|
||||
const pixels = ctx.createImageData(raster.width, raster.height);
|
||||
pixels.data.set(raster.rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
if (kind === 'tileset') {
|
||||
shell = createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
height: WEATHER_SHELL_HEIGHTS.wind,
|
||||
});
|
||||
shell.setImage(texture);
|
||||
imageryKind = kind;
|
||||
shell.setAlpha(overlay === 'temperature' ? 1 : 0.85);
|
||||
updateImageryFade(viewer.scene.camera, true);
|
||||
viewer.scene?.requestRender?.();
|
||||
return;
|
||||
}
|
||||
const provider = new cesium.SingleTileImageryProvider({
|
||||
url: texture.toDataURL('image/png'),
|
||||
tileWidth: raster.width,
|
||||
tileHeight: raster.height,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
});
|
||||
imagery = collection.addImageryProvider(provider);
|
||||
orderWeatherImagery(collection, imagery, 0);
|
||||
imageryCollection = collection;
|
||||
imageryKind = kind;
|
||||
// Temperature colors carry quantitative meaning; double transparency
|
||||
// blends orange heat into blue ocean and obscures useful gradients.
|
||||
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
||||
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
||||
imageryError = 'Globe field image unavailable';
|
||||
});
|
||||
viewer.scene?.requestRender?.();
|
||||
} catch {
|
||||
removeImagery();
|
||||
imageryError = 'Globe field image unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function rehome() {
|
||||
if (!snapshot || overlay === 'none') return;
|
||||
const { collection, kind } = getHost();
|
||||
const wasHidden = imageryError === NO_IMAGERY_HOST;
|
||||
if ((imagery || shell) && kind !== 'none' && kind !== imageryKind) {
|
||||
installImagery();
|
||||
onStatusChange?.();
|
||||
return;
|
||||
}
|
||||
// A host without imagery hides the retained shell until a host returns.
|
||||
if (shell && kind === 'none') shell.setShow(false);
|
||||
if (imagery && collection !== imageryCollection) {
|
||||
imageryCollection?.remove(imagery, false);
|
||||
imageryCollection = collection;
|
||||
if (collection) {
|
||||
collection.add(imagery);
|
||||
orderWeatherImagery(collection, imagery, 0);
|
||||
}
|
||||
viewerReady()?.scene?.requestRender?.();
|
||||
}
|
||||
if ((gpuActive || kind === 'tileset') && kind !== 'none')
|
||||
updateImageryFade(viewerReady()?.scene?.camera, true);
|
||||
if (kind === 'none') imageryError = NO_IMAGERY_HOST;
|
||||
else if (wasHidden) {
|
||||
imageryError = null;
|
||||
if (!imagery && !shell) installImagery();
|
||||
}
|
||||
if (wasHidden !== (imageryError === NO_IMAGERY_HOST)) onStatusChange?.();
|
||||
}
|
||||
|
||||
/** No idle animation for pause/reduced motion; scene events repaint only changed views. */
|
||||
function viewChanged() {
|
||||
if (gpuActive) {
|
||||
const viewer = viewerReady();
|
||||
if (viewer) updateImageryFade(viewer.scene.camera);
|
||||
const narrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
|
||||
let visibilityUpdated = false;
|
||||
if (running && field && !hidden() && narrow !== gpuNarrow) {
|
||||
gpuNarrow = narrow;
|
||||
gpuActive = gpu.setField(field);
|
||||
if (!gpuActive && viewer) {
|
||||
resize(viewer);
|
||||
seed(viewer.scene, makeOccluder(viewer.scene));
|
||||
}
|
||||
motionChanged();
|
||||
visibilityUpdated = true;
|
||||
}
|
||||
if (gpuActive && viewer && !visibilityUpdated) {
|
||||
gpuVisible = gpu.updateVisibility(viewer.scene.camera);
|
||||
if (gpuVisible) schedule();
|
||||
else {
|
||||
cancel();
|
||||
lastTime = null;
|
||||
}
|
||||
}
|
||||
const ready = gpuActive ? gpu.getDiagnostics().ready : null;
|
||||
if (ready !== reportedGpuReady) {
|
||||
reportedGpuReady = ready;
|
||||
onStatusChange?.();
|
||||
}
|
||||
return; // Cesium projects persistent curves; only a responsive budget change rebuilds them.
|
||||
}
|
||||
if (!running || !field || hidden() || !canvas) return;
|
||||
const viewer = viewerReady();
|
||||
if (!viewer) return;
|
||||
const resized = resize(viewer);
|
||||
if (resized || checkCamera(viewer.scene)) {
|
||||
cameraChanged = true;
|
||||
if (still()) paint(viewer, 0, true);
|
||||
else schedule();
|
||||
}
|
||||
}
|
||||
function motionChanged() {
|
||||
reducedMotion = media?.matches === true;
|
||||
cancel();
|
||||
lastTime = null;
|
||||
cameraChanged = true;
|
||||
gpu.setOptions({ overlay, paused: still() });
|
||||
if (!running || !field || hidden()) return;
|
||||
const viewer = viewerReady();
|
||||
if (gpuActive) {
|
||||
if (viewer) updateImageryFade(viewer.scene.camera);
|
||||
gpuVisible = viewer ? gpu.updateVisibility(viewer.scene.camera) : false;
|
||||
if (gpuVisible) {
|
||||
gpu.tick(flowTime);
|
||||
viewer.scene.requestRender();
|
||||
schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (still() && viewer) paint(viewer, 0, true);
|
||||
else schedule();
|
||||
}
|
||||
function listen(target, event, callback) {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(event, callback);
|
||||
removers.push(() => target.removeEventListener(event, callback));
|
||||
}
|
||||
function cameraSettled() {
|
||||
if (gpuActive || imageryKind === 'tileset')
|
||||
updateImageryFade(viewerReady()?.scene?.camera, true);
|
||||
cameraMoved();
|
||||
}
|
||||
function cameraMoved() {
|
||||
viewChanged();
|
||||
// A parked request-render scene may never run preRender until we wake it.
|
||||
if (gpuActive && running && field && !hidden())
|
||||
viewerReady()?.scene?.requestRender?.();
|
||||
}
|
||||
function listenScene(event, callback) {
|
||||
if (!event?.addEventListener) return;
|
||||
const remove = event.addEventListener(callback);
|
||||
removers.push(
|
||||
typeof remove === 'function'
|
||||
? remove
|
||||
: () => event.removeEventListener(callback),
|
||||
);
|
||||
}
|
||||
function attachListeners() {
|
||||
if (removers.length) return;
|
||||
media = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
|
||||
reducedMotion = media?.matches === true;
|
||||
listen(media, 'change', motionChanged);
|
||||
listen(globalThis.document, 'visibilitychange', motionChanged);
|
||||
listen(globalThis, 'resize', cameraMoved);
|
||||
listen(eventTarget, 'gev:map-stack-changed', rehome);
|
||||
const viewer = viewerReady();
|
||||
const camera = viewer?.scene?.camera;
|
||||
listenScene(viewer?.scene?.preRender, viewChanged);
|
||||
listenScene(camera?.moveEnd, cameraSettled);
|
||||
}
|
||||
function detachListeners() {
|
||||
for (const remove of removers) remove();
|
||||
removers = [];
|
||||
media = null;
|
||||
}
|
||||
|
||||
function paint(viewer, dt, staticFrame, fadeSeconds = dt) {
|
||||
const scene = viewer.scene;
|
||||
const resized = resize(viewer);
|
||||
const changed = checkCamera(scene) || cameraChanged || resized;
|
||||
const occluder = makeOccluder(scene);
|
||||
if (changed) {
|
||||
clearPixels();
|
||||
updateBounds(scene);
|
||||
cameraChanged = false;
|
||||
}
|
||||
if (particles.length !== budget()) seed(scene, occluder);
|
||||
if (staticFrame) clearPixels();
|
||||
context.globalAlpha = 1;
|
||||
context.globalCompositeOperation = 'destination-out';
|
||||
context.fillStyle = `rgba(0,0,0,${trailEraseAlpha(fadeSeconds)})`;
|
||||
context.fillRect(0, 0, cssWidth, cssHeight);
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
context.lineCap = 'round';
|
||||
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
|
||||
const speedScale = Math.max(0.002, Math.min(4000, cameraHeight / 400));
|
||||
const advection = { speedScale };
|
||||
// Stroke length is a display sample of the local field, not frame travel.
|
||||
// At low FPS capped advection still moves the head by dt, but a readable
|
||||
// world-anchored tail survives without extra projections or screen stretching.
|
||||
const strokeSeconds = staticFrame ? 3 : 2;
|
||||
painted = 0;
|
||||
for (const particle of particles) {
|
||||
sampleWind(field, particle.lon, particle.lat, wind);
|
||||
if (!staticFrame) {
|
||||
advectParticle(particle, wind, dt, advection);
|
||||
particle.age += dt;
|
||||
}
|
||||
let point = project(scene, occluder, particle.lon, particle.lat, screenB);
|
||||
if (changed && !inView(point)) {
|
||||
// Refill newly exposed regions while retaining world anchors still in view.
|
||||
spawn(particle, scene, occluder);
|
||||
sampleWind(field, particle.lon, particle.lat, wind);
|
||||
point = project(scene, occluder, particle.lon, particle.lat, screenB);
|
||||
}
|
||||
if (particle.age > particle.life || !inView(point)) {
|
||||
spawn(particle, scene, occluder);
|
||||
continue;
|
||||
}
|
||||
before.lon = particle.lon;
|
||||
before.lat = particle.lat;
|
||||
advectParticle(before, wind, -strokeSeconds, advection);
|
||||
const previous = project(
|
||||
scene,
|
||||
occluder,
|
||||
before.lon,
|
||||
before.lat,
|
||||
screenA,
|
||||
);
|
||||
if (!inView(previous)) continue;
|
||||
const startX = previous.x;
|
||||
const startY = previous.y;
|
||||
const visibility = previous.visibility;
|
||||
const distance = Math.hypot(point.x - startX, point.y - startY);
|
||||
// Perspective/frustum jumps and antimeridian discontinuities never become strokes.
|
||||
if (
|
||||
Math.abs(before.lon - particle.lon) > 180 ||
|
||||
distance > Math.min(80, cssWidth * 0.08)
|
||||
)
|
||||
continue;
|
||||
const alpha =
|
||||
Math.min(visibility, point.visibility) *
|
||||
Math.min(1, particle.age / 0.8, (particle.life - particle.age) / 1.2);
|
||||
if (alpha <= 0) continue;
|
||||
const speed = Math.hypot(wind.u, wind.v);
|
||||
context.globalAlpha = alpha * 0.55;
|
||||
context.lineWidth = 1.0;
|
||||
context.strokeStyle =
|
||||
overlay === 'none' ? windTrailColor(speed) : '#9ddce4';
|
||||
context.beginPath();
|
||||
context.moveTo(startX, startY);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
if (distance > 0.6) {
|
||||
context.globalAlpha = alpha * 0.9;
|
||||
context.strokeStyle = '#d6f4f5';
|
||||
context.lineWidth = 1.35;
|
||||
context.beginPath();
|
||||
context.moveTo(
|
||||
startX + (point.x - startX) * 0.72,
|
||||
startY + (point.y - startY) * 0.72,
|
||||
);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
}
|
||||
if (staticFrame) {
|
||||
// Preserve direction at rest with a small arrowhead, without moving the field.
|
||||
if (distance > 2) {
|
||||
const ux = (point.x - startX) / distance;
|
||||
const uy = (point.y - startY) / distance;
|
||||
context.beginPath();
|
||||
context.moveTo(point.x - ux * 3 - uy * 2, point.y - uy * 3 + ux * 2);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.lineTo(point.x - ux * 3 + uy * 2, point.y - uy * 3 - ux * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
painted++;
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
frame = null;
|
||||
if (!running || !field || hidden() || still()) return;
|
||||
if (gpuActive && !gpuVisible) {
|
||||
lastTime = null;
|
||||
return;
|
||||
}
|
||||
const viewer = viewerReady();
|
||||
if (!viewer) return;
|
||||
try {
|
||||
const resized = !gpuActive && resize(viewer);
|
||||
if (resized) cameraChanged = true;
|
||||
const elapsed =
|
||||
lastTime === null ? 1 / 60 : Math.max(0, (time - lastTime) / 1000);
|
||||
// Limit full particle paints to 30 Hz while preserving elapsed-time motion/fading.
|
||||
if (lastTime === null || elapsed >= 0.032 || cameraChanged) {
|
||||
if (gpuActive) {
|
||||
flowTime += elapsed;
|
||||
gpu.tick(flowTime);
|
||||
viewer.scene.requestRender();
|
||||
cameraChanged = false;
|
||||
} else paint(viewer, Math.min(0.1, elapsed), false, elapsed);
|
||||
lastTime = time;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!warned) {
|
||||
console.warn('[Data:Wind] frame failed:', error?.message || error);
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
return {
|
||||
rehome,
|
||||
attach() {
|
||||
if (canvas) return;
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.dataset.gevWind = '1';
|
||||
Object.assign(canvas.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1',
|
||||
});
|
||||
canvas.setAttribute?.('aria-hidden', 'true');
|
||||
context = canvas.getContext('2d');
|
||||
container.appendChild(canvas);
|
||||
},
|
||||
setField(next) {
|
||||
const nextField = next?.grid
|
||||
? { ...next.grid, u: next.u, v: next.v }
|
||||
: next;
|
||||
const viewer = viewerReady();
|
||||
const nextNarrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
|
||||
const reuseGeometry =
|
||||
gpuActive &&
|
||||
gpuNarrow === nextNarrow &&
|
||||
gpu.supported() &&
|
||||
sameWind(snapshot, next, field, nextField);
|
||||
snapshot = next;
|
||||
field = nextField;
|
||||
cameraChanged = true;
|
||||
lastTime = null;
|
||||
cancel();
|
||||
if (canvas && field) {
|
||||
gpuNarrow = nextNarrow;
|
||||
if (!reuseGeometry) {
|
||||
gpuActive = gpu.supported() && gpu.setField(field);
|
||||
reportedGpuReady = gpuActive ? gpu.getDiagnostics().ready : null;
|
||||
flowTime = 0;
|
||||
}
|
||||
clearPixels();
|
||||
particles = [];
|
||||
if (viewer && !gpuActive) {
|
||||
resize(viewer);
|
||||
seed(viewer.scene, makeOccluder(viewer.scene));
|
||||
}
|
||||
installImagery();
|
||||
relief.attach();
|
||||
motionChanged();
|
||||
}
|
||||
},
|
||||
setOptions(options = {}) {
|
||||
const nextOverlay = ['none', 'speed', 'temperature', 'pressure'].includes(
|
||||
options.overlay,
|
||||
)
|
||||
? options.overlay
|
||||
: overlay;
|
||||
const newPaused =
|
||||
typeof options.paused === 'boolean' ? options.paused : paused;
|
||||
const changedOverlay = nextOverlay !== overlay;
|
||||
const changedPause = newPaused !== paused;
|
||||
overlay = nextOverlay;
|
||||
paused = newPaused;
|
||||
if (changedOverlay) {
|
||||
installImagery();
|
||||
clearPixels();
|
||||
}
|
||||
if (changedOverlay || changedPause) motionChanged();
|
||||
},
|
||||
start() {
|
||||
if (running || !canvas) return;
|
||||
running = true;
|
||||
attachListeners();
|
||||
rehome();
|
||||
motionChanged();
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
cancel();
|
||||
lastTime = null;
|
||||
detachListeners();
|
||||
gpu.setOptions({ overlay, paused: true });
|
||||
},
|
||||
clear() {
|
||||
cancel();
|
||||
snapshot = null;
|
||||
field = null;
|
||||
particles = [];
|
||||
painted = 0;
|
||||
gpu.clear();
|
||||
relief.clear();
|
||||
gpuActive = false;
|
||||
gpuVisible = false;
|
||||
reportedGpuReady = null;
|
||||
flowTime = 0;
|
||||
removeImagery();
|
||||
imageryError = null;
|
||||
clearPixels();
|
||||
},
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.clear();
|
||||
gpu.destroy();
|
||||
relief.destroy();
|
||||
canvas?.remove();
|
||||
canvas = null;
|
||||
context = null;
|
||||
},
|
||||
getParticleCount() {
|
||||
return gpuActive ? gpu.getParticleCount() : particles.length;
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
overlay,
|
||||
paused,
|
||||
reducedMotion,
|
||||
hidden: hidden(),
|
||||
particleCount: gpuActive ? gpu.getParticleCount() : particles.length,
|
||||
renderMode: gpuActive ? 'gpu-streamlines' : 'canvas-fallback',
|
||||
gpu: gpu.getDiagnostics(),
|
||||
relief: relief.getDiagnostics(),
|
||||
painted,
|
||||
framePending: frame !== null,
|
||||
imageryActive: imagery !== null || shell !== null,
|
||||
host: shell ? 'shell' : imagery ? 'globe' : null,
|
||||
shell: shell?.getDiagnostics() ?? null,
|
||||
imageryError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
import { readWindBody } from '../../sources/windBody.js';
|
||||
|
||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||
export function createWindSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 45_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
|
||||
if (!['gfs', 'ifs'].includes(model))
|
||||
throw new Error('Unknown wind model');
|
||||
if (!['none', 'temperature', 'pressure'].includes(overlay))
|
||||
throw new Error('Unknown weather overlay');
|
||||
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Wind request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
const active = controller.signal;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/api/wind/manifest?${query}`, {
|
||||
signal: active,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
||||
const manifest = JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
await readWindBody(response, 16_384, active),
|
||||
),
|
||||
);
|
||||
if (manifest?.unavailable) return manifest;
|
||||
const grid = manifest?.grid;
|
||||
const scalar = manifest?.scalar;
|
||||
const scalarError = manifest?.scalarError;
|
||||
const optionalUnavailable =
|
||||
overlay !== 'none' &&
|
||||
scalar === undefined &&
|
||||
scalarError ===
|
||||
(overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable');
|
||||
const scalarValid =
|
||||
overlay === 'none'
|
||||
? scalar === undefined && scalarError === undefined
|
||||
: optionalUnavailable ||
|
||||
(scalarError === undefined &&
|
||||
scalar?.kind === overlay &&
|
||||
scalar.units === (overlay === 'temperature' ? '°C' : 'hPa') &&
|
||||
scalar.level ===
|
||||
(overlay === 'temperature'
|
||||
? '2 m above ground'
|
||||
: 'mean sea level'));
|
||||
if (
|
||||
manifest?.model !== model ||
|
||||
(manifest.overlay ?? 'none') !== overlay ||
|
||||
!scalarValid ||
|
||||
!grid ||
|
||||
!Number.isInteger(grid.nx) ||
|
||||
!Number.isInteger(grid.ny) ||
|
||||
grid.nx < 1 ||
|
||||
grid.ny < 1 ||
|
||||
grid.nx * grid.ny > 1_000_000 ||
|
||||
![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) ||
|
||||
grid.dx <= 0 ||
|
||||
grid.dy <= 0 ||
|
||||
Math.abs(grid.nx * grid.dx - 360) > 0.01 ||
|
||||
!new RegExp(
|
||||
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
|
||||
).test(manifest.gridUrl)
|
||||
)
|
||||
throw new Error('Malformed wind manifest');
|
||||
const count = grid.nx * grid.ny;
|
||||
const expectedBytes = count * (scalar ? 12 : 8);
|
||||
const gridResponse = await fetchImpl(manifest.gridUrl, {
|
||||
signal: active,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!gridResponse.ok)
|
||||
throw new Error(`Wind HTTP ${gridResponse.status}`);
|
||||
const bytes = await readWindBody(gridResponse, expectedBytes, active);
|
||||
if (bytes.byteLength !== expectedBytes)
|
||||
throw new Error('Malformed wind grid');
|
||||
const values = new Float32Array(bytes.buffer);
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Malformed wind grid');
|
||||
return {
|
||||
...manifest,
|
||||
u: values.slice(0, count),
|
||||
v: values.slice(count, count * 2),
|
||||
...(scalar
|
||||
? { scalar: { ...scalar, values: values.slice(count * 2) } }
|
||||
: {}),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindSource } from './source.js';
|
||||
const manifest = {
|
||||
model: 'gfs',
|
||||
grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
gridUrl: '/api/wind/grid/gfs-test.bin?model=gfs',
|
||||
};
|
||||
const response = (value) =>
|
||||
new Response(
|
||||
typeof value === 'object' && !(value instanceof ArrayBuffer)
|
||||
? JSON.stringify(value)
|
||||
: value,
|
||||
);
|
||||
test('wind source splits a bounded field and preserves unavailable responses', async () => {
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? manifest
|
||||
: Float32Array.from([1, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
const field = await source.getSnapshot();
|
||||
assert.deepEqual([...field.u], [1, 2]);
|
||||
assert.deepEqual([...field.v], [3, 4]);
|
||||
const unavailable = createWindSource({
|
||||
fetchImpl: async () => response({ unavailable: true }),
|
||||
});
|
||||
assert.equal((await unavailable.getSnapshot()).unavailable, true);
|
||||
});
|
||||
test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => {
|
||||
for (const bad of [
|
||||
{ ...manifest, gridUrl: 'https://example.com/exfil' },
|
||||
{ ...manifest, grid: { ...manifest.grid, nx: 1e9 } },
|
||||
]) {
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(bad);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed/);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? manifest
|
||||
: Float32Array.from([NaN, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed/);
|
||||
});
|
||||
test('wind source timeout aborts the underlying fetch', async () => {
|
||||
const source = createWindSource({
|
||||
timeoutMs: 5,
|
||||
fetchImpl: (_, { signal }) =>
|
||||
new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /timed out/);
|
||||
});
|
||||
|
||||
test('wind source requests optional companions and splits exact U/V/scalar byte regions', async () => {
|
||||
for (const [overlay, units, level, expected] of [
|
||||
['temperature', '°C', '2 m above ground', [-5, 25]],
|
||||
['pressure', 'hPa', 'mean sea level', [995, 1025]],
|
||||
]) {
|
||||
const scalar = { kind: overlay, units, level };
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay,
|
||||
scalar,
|
||||
gridUrl: `/api/wind/grid/gfs-test-${overlay}.bin?model=gfs&overlay=${overlay}`,
|
||||
};
|
||||
const calls = [];
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return response(
|
||||
url.includes('/manifest?')
|
||||
? variant
|
||||
: Float32Array.from([1, 2, 3, 4, ...expected]).buffer,
|
||||
);
|
||||
},
|
||||
});
|
||||
const field = await source.getSnapshot({ overlay });
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
`/api/wind/manifest?model=gfs&overlay=${overlay}`,
|
||||
);
|
||||
assert.equal(calls[1].url, variant.gridUrl);
|
||||
assert.ok(calls.every((call) => call.options.redirect === 'error'));
|
||||
assert.deepEqual([...field.u], [1, 2]);
|
||||
assert.deepEqual([...field.v], [3, 4]);
|
||||
assert.deepEqual([...field.scalar.values], expected);
|
||||
assert.equal(field.scalar.units, units);
|
||||
assert.equal(field.scalar.level, level);
|
||||
}
|
||||
});
|
||||
|
||||
test('wind source rejects scalar contract mismatches before fetching a grid', async () => {
|
||||
const base = {
|
||||
...manifest,
|
||||
overlay: 'temperature',
|
||||
scalar: { kind: 'temperature', units: '°C', level: '2 m above ground' },
|
||||
gridUrl:
|
||||
'/api/wind/grid/gfs-test-temperature.bin?model=gfs&overlay=temperature',
|
||||
};
|
||||
for (const bad of [
|
||||
{ ...base, overlay: 'pressure' },
|
||||
{ ...base, scalar: { ...base.scalar, units: 'K' } },
|
||||
{ ...base, scalar: { ...base.scalar, level: 'surface' } },
|
||||
{ ...base, scalar: undefined },
|
||||
{
|
||||
...base,
|
||||
gridUrl: base.gridUrl.replace('overlay=temperature', 'overlay=pressure'),
|
||||
},
|
||||
{ ...base, gridUrl: `https://example.com${base.gridUrl}` },
|
||||
]) {
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(bad);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ overlay: 'temperature' }),
|
||||
/Malformed/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(base);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ overlay: 'humidity' }), /Unknown/);
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(
|
||||
source.getSnapshot(),
|
||||
/Malformed/,
|
||||
'wind-only does not accept an unsolicited scalar',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind source bounds optional scalar length and rejects non-finite scalar values', async () => {
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay: 'pressure',
|
||||
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
|
||||
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
|
||||
};
|
||||
for (const values of [
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4, 1000, 1001, 1002],
|
||||
[1, 2, 3, 4, NaN, 1000],
|
||||
]) {
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest') ? variant : Float32Array.from(values).buffer,
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ overlay: 'pressure' }),
|
||||
/Malformed|budget/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling an optional scalar stream cancels the active grid body', async () => {
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay: 'pressure',
|
||||
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
|
||||
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
|
||||
};
|
||||
let started;
|
||||
const ready = new Promise((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let cancelled = false;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) => {
|
||||
if (url.includes('manifest')) return response(variant);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start() {
|
||||
started();
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = source.getSnapshot({
|
||||
overlay: 'pressure',
|
||||
signal: controller.signal,
|
||||
});
|
||||
await ready;
|
||||
await Promise.resolve();
|
||||
controller.abort(new Error('overlay changed'));
|
||||
await assert.rejects(result, /overlay changed/);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('wind source retains usable wind when the requested scalar is unavailable', async () => {
|
||||
for (const overlay of ['temperature', 'pressure']) {
|
||||
const partial = {
|
||||
...manifest,
|
||||
overlay,
|
||||
scalarError:
|
||||
overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable',
|
||||
gridUrl: `/api/wind/grid/gfs-test-${overlay}-wind-only.bin?model=gfs&overlay=${overlay}`,
|
||||
};
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? partial
|
||||
: Float32Array.from([1, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
const result = await source.getSnapshot({ overlay });
|
||||
assert.equal(result.scalar, undefined);
|
||||
assert.equal(result.scalarError, partial.scalarError);
|
||||
assert.deepEqual([...result.u], [1, 2]);
|
||||
assert.deepEqual([...result.v], [3, 4]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { metersPerDegreeLon, normalizeLongitude, sampleWind } from './model.js';
|
||||
|
||||
// Desktop density is bounded independently of viewport zoom; camera motion
|
||||
// never triggers a regional rebake. Narrow-screen cost stays at 1200 paths.
|
||||
export const WIND_PATH_LIMIT = 7200;
|
||||
export const WIND_NARROW_PATH_LIMIT = 1200;
|
||||
export const WIND_PATH_STEPS = 16;
|
||||
export const WIND_DISPLAY_HEIGHT_METERS = 12000;
|
||||
export const WIND_CELL_DEGREES = 30;
|
||||
const DEGREE_METERS = 111320;
|
||||
const GOLDEN_ANGLE = 137.50776405003785;
|
||||
|
||||
/** One bounded midpoint step. The integration interval is field time, not playback time. */
|
||||
function advance(field, point, seconds, scratch) {
|
||||
sampleWind(field, point[0], point[1], scratch);
|
||||
if (
|
||||
!Number.isFinite(scratch.u + scratch.v) ||
|
||||
Math.hypot(scratch.u, scratch.v) < 0.15
|
||||
)
|
||||
return null;
|
||||
const lonRate = scratch.u / metersPerDegreeLon(point[1]);
|
||||
const latRate = scratch.v / DEGREE_METERS;
|
||||
// Bound each geographic segment, including fast winds near the poles.
|
||||
const dt =
|
||||
Math.sign(seconds) *
|
||||
Math.min(
|
||||
Math.abs(seconds),
|
||||
0.75 / Math.max(Math.abs(lonRate), Math.abs(latRate), 1e-12),
|
||||
);
|
||||
const midLat = point[1] + latRate * dt * 0.5;
|
||||
if (Math.abs(midLat) > 88.5) return null;
|
||||
sampleWind(field, point[0] + lonRate * dt * 0.5, midLat, scratch);
|
||||
if (!Number.isFinite(scratch.u + scratch.v)) return null;
|
||||
const lon = normalizeLongitude(
|
||||
point[0] + (scratch.u * dt) / metersPerDegreeLon(midLat),
|
||||
);
|
||||
const lat = point[1] + (scratch.v * dt) / DEGREE_METERS;
|
||||
if (Math.abs(lat) > 88.5 || Math.abs(lon - point[0]) > 180) return null;
|
||||
if (Math.abs(lon - point[0]) + Math.abs(lat - point[1]) < 1e-8) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake geographic streamlines once from an immutable forecast snapshot.
|
||||
* Equal-area seeds avoid polar overpopulation. Increasing path position always
|
||||
* follows the wind; paths stop at the map seam, poles, calm or missing samples.
|
||||
*/
|
||||
export function bakeWindStreamlines(
|
||||
field,
|
||||
{ count = WIND_PATH_LIMIT, steps = WIND_PATH_STEPS, stepSeconds = 1800 } = {},
|
||||
) {
|
||||
if (
|
||||
!field ||
|
||||
field.nx < 2 ||
|
||||
field.ny < 2 ||
|
||||
!(field.dx > 0) ||
|
||||
!(field.dy > 0) ||
|
||||
!field.u ||
|
||||
!field.v
|
||||
)
|
||||
return [];
|
||||
const budget = Math.min(
|
||||
WIND_PATH_LIMIT,
|
||||
Math.max(0, Math.floor(Number.isFinite(count) ? count : WIND_PATH_LIMIT)),
|
||||
);
|
||||
const halfSteps = Math.min(
|
||||
WIND_PATH_STEPS,
|
||||
Math.max(1, Math.floor(Number.isFinite(steps) ? steps : WIND_PATH_STEPS)),
|
||||
);
|
||||
const interval = Math.min(
|
||||
3600,
|
||||
Math.max(1, Number.isFinite(stepSeconds) ? stepSeconds : 1800),
|
||||
);
|
||||
const paths = [];
|
||||
const scratch = { u: 0, v: 0 };
|
||||
for (let seed = 0; seed < budget; seed++) {
|
||||
const lon = normalizeLongitude(seed * GOLDEN_ANGLE);
|
||||
const lat =
|
||||
(Math.asin(
|
||||
(((seed + 0.5) / budget) * 2 - 1) * Math.sin((88 * Math.PI) / 180),
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
const center = [lon, lat];
|
||||
sampleWind(field, lon, lat, scratch);
|
||||
const speed = Math.hypot(scratch.u, scratch.v);
|
||||
if (!Number.isFinite(speed) || speed < 0.15) continue;
|
||||
const backward = [];
|
||||
const forward = [];
|
||||
for (const [direction, points] of [
|
||||
[-1, backward],
|
||||
[1, forward],
|
||||
]) {
|
||||
let point = center;
|
||||
for (let step = 0; step < halfSteps; step++) {
|
||||
point = advance(field, point, direction * interval, scratch);
|
||||
if (!point) break;
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
const coordinates = [...backward.reverse(), center, ...forward];
|
||||
if (coordinates.length < 3) continue;
|
||||
paths.push({
|
||||
coordinates,
|
||||
speed,
|
||||
seed,
|
||||
phase: ((seed + 1) * 0.6180339887498949) % 1,
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Group baked paths by their middle coordinate, preserving path and cell order. */
|
||||
export function groupWindPaths(paths, cellDegrees = WIND_CELL_DEGREES) {
|
||||
if (!Number.isFinite(cellDegrees) || cellDegrees <= 0 || cellDegrees > 180)
|
||||
throw new RangeError('Wind cell size must be between 0 and 180 degrees');
|
||||
const columns = Math.ceil(360 / cellDegrees);
|
||||
const rows = Math.ceil(180 / cellDegrees);
|
||||
const cells = new Map();
|
||||
for (const path of paths) {
|
||||
const [lon, lat] =
|
||||
path.coordinates[Math.floor(path.coordinates.length / 2)];
|
||||
const column = Math.floor((normalizeLongitude(lon) + 180) / cellDegrees);
|
||||
const row = Math.min(
|
||||
rows - 1,
|
||||
Math.max(0, Math.floor((lat + 90) / cellDegrees)),
|
||||
);
|
||||
const id = row * columns + column;
|
||||
let cell = cells.get(id);
|
||||
if (!cell) {
|
||||
cell = { id, paths: [] };
|
||||
cells.set(id, cell);
|
||||
}
|
||||
cell.paths.push(path);
|
||||
}
|
||||
return [...cells.values()];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bakeWindStreamlines,
|
||||
groupWindPaths,
|
||||
WIND_CELL_DEGREES,
|
||||
WIND_PATH_LIMIT,
|
||||
WIND_PATH_STEPS,
|
||||
} from './streamlines.js';
|
||||
|
||||
function field(u = 12, v = 0) {
|
||||
return {
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
u: new Float32Array(12).fill(u),
|
||||
v: new Float32Array(12).fill(v),
|
||||
};
|
||||
}
|
||||
|
||||
test('streamline bake is deterministic, equal-area and strictly bounded', () => {
|
||||
const first = bakeWindStreamlines(field(), { count: 24 });
|
||||
assert.deepEqual(first, bakeWindStreamlines(field(), { count: 24 }));
|
||||
assert.equal(first.length, 24);
|
||||
for (const path of first) {
|
||||
assert.ok(path.coordinates.length <= WIND_PATH_STEPS * 2 + 1);
|
||||
assert.ok(path.coordinates.length >= 3);
|
||||
for (let i = 1; i < path.coordinates.length; i++) {
|
||||
assert.ok(
|
||||
path.coordinates[i][0] > path.coordinates[i - 1][0],
|
||||
'eastward field has consistently eastward paths',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(path.coordinates[i][0] - path.coordinates[i - 1][0]) < 1,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(path.coordinates[i][1] - path.coordinates[i - 1][1]) < 1e-9,
|
||||
);
|
||||
}
|
||||
}
|
||||
const oversized = bakeWindStreamlines(field(), {
|
||||
count: 10000,
|
||||
steps: 10000,
|
||||
});
|
||||
assert.ok(oversized.length <= WIND_PATH_LIMIT);
|
||||
assert.ok(oversized.every((path) => path.coordinates.length <= 33));
|
||||
});
|
||||
|
||||
test('bake stops at poles, seams, missing and calm data without invalid geometry', () => {
|
||||
assert.deepEqual(bakeWindStreamlines(field(0, 0)), []);
|
||||
assert.deepEqual(bakeWindStreamlines(field(NaN, 1)), []);
|
||||
assert.deepEqual(bakeWindStreamlines(null), []);
|
||||
for (const path of bakeWindStreamlines(field(150, 80), { count: 90 })) {
|
||||
for (let i = 0; i < path.coordinates.length; i++) {
|
||||
const [lon, lat] = path.coordinates[i];
|
||||
assert.ok(Number.isFinite(lon + lat));
|
||||
assert.ok(Math.abs(lat) <= 88.5);
|
||||
assert.ok(lon >= -180 && lon < 180);
|
||||
if (i) assert.ok(Math.abs(lon - path.coordinates[i - 1][0]) < 180);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('midpoint integration bends paths with a changing northward component', () => {
|
||||
const snapshot = field(20, 0);
|
||||
snapshot.v = new Float32Array([
|
||||
-20, -10, 0, 10, -20, -10, 0, 10, -20, -10, 0, 10,
|
||||
]);
|
||||
const path = bakeWindStreamlines(snapshot, { count: 1 })[0];
|
||||
assert.equal(path.coordinates.length, 33);
|
||||
const middle = path.coordinates[16];
|
||||
assert.equal(middle[0], 0);
|
||||
assert.ok(path.coordinates[0][1] > middle[1]);
|
||||
assert.ok(path.coordinates.at(-1)[1] > middle[1]);
|
||||
});
|
||||
|
||||
test('regional grouping preserves every baked path and ordering within each cell', () => {
|
||||
const paths = bakeWindStreamlines(field());
|
||||
const before = structuredClone(paths);
|
||||
const groups = groupWindPaths(paths);
|
||||
assert.equal(WIND_CELL_DEGREES, 30);
|
||||
assert.equal(groups.length, 72);
|
||||
assert.deepEqual(paths, before, 'grouping leaves the bake untouched');
|
||||
const grouped = groups.flatMap((cell) => cell.paths);
|
||||
assert.equal(grouped.length, paths.length);
|
||||
assert.equal(new Set(grouped).size, paths.length);
|
||||
for (const cell of groups) {
|
||||
assert.deepEqual(
|
||||
cell.paths,
|
||||
paths.filter((path) => cell.paths.includes(path)),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(groups, groupWindPaths(paths));
|
||||
assert.deepEqual(groupWindPaths([]), []);
|
||||
});
|
||||
|
||||
test('grouping uses the middle coordinate, wraps longitude and clamps polar cell edges', () => {
|
||||
const path = (lon, lat) => ({
|
||||
coordinates: [
|
||||
[-15, -15],
|
||||
[lon, lat],
|
||||
[150, 80],
|
||||
],
|
||||
});
|
||||
const a = path(-180, -90),
|
||||
b = path(180, -90),
|
||||
c = path(540, -90);
|
||||
const north = path(179.9, 90),
|
||||
boundary = path(-150, -60);
|
||||
const groups = groupWindPaths([a, north, boundary, b, c]);
|
||||
assert.deepEqual(groups, [
|
||||
{ id: 0, paths: [a, b, c] },
|
||||
{ id: 71, paths: [north] },
|
||||
{ id: 13, paths: [boundary] },
|
||||
]);
|
||||
assert.equal(groupWindPaths([a, north], 90).length, 2);
|
||||
for (const size of [0, -30, NaN, Infinity, 181])
|
||||
assert.throws(() => groupWindPaths([], size), RangeError);
|
||||
});
|
||||
Reference in new issue
Block a user