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:
Said TurkseverandClaude Fable 5.1 committed 2026-09-23 19:05:59 +01:00
commit 817716f0c1
234 files changed
+45441 -1430

No files matched your search

+1
View File
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
createCctvProjectionOverlayEntry:
parts.projection.createCctvProjectionOverlayEntry,
getActiveVideoElement: parts.projection.getActiveVideoElement,
setCctvCardPresentationOptions:
parts.cards.setCctvCardPresentationOptions,
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
+3
View File
@@ -83,6 +83,9 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+72 -19
View File
@@ -1,3 +1,4 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
@@ -207,6 +208,7 @@ export function createProjection({
// only re-uploads the plane texture when there is genuinely new content.
canvasStamp: 1,
lastSwappedCanvasStamp: 0,
disposed: false,
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
@@ -214,16 +216,49 @@ export function createProjection({
if (mode === 'video') {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = parts.frames.mediaUrlFor(record.camera);
video.addEventListener('canplay', () => {
video.play().catch(() => {});
});
// Cesium sizes the video texture from the element's width/height
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
parts.frames.mediaUrlFor(record.camera),
feedType,
{
onFailure: () => {
if (runtime.disposed) return;
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
},
);
} else {
const img = new Image();
img.decoding = 'async';
@@ -254,7 +289,7 @@ export function createProjection({
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: mode === 'video' && runtime.video ? runtime.video : canvas,
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
@@ -271,7 +306,8 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection) return record.projection;
if (record.projection && !record.projection.disposed)
return record.projection;
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
@@ -287,7 +323,15 @@ export function createProjection({
*/
function destroyProjectionRuntime(runtime) {
if (!runtime) return;
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
runtime.image.onload = null;
runtime.image.onerror = null;
runtime.image.src = '';
}
if (runtime.video) {
runtime.video.pause();
runtime.video.removeAttribute('src');
@@ -324,10 +368,7 @@ export function createProjection({
const active = parts.selection.getActiveRecord();
if (layerState._enabled && layerState._showProjection && active) {
ensureProjectionRuntime(active);
if (active.projection?.video) {
active.projection.video.play().catch(() => {});
}
if (active.projection) {
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
}
@@ -355,22 +396,34 @@ export function createProjection({
* @param {string|null} activeId - ID of the currently active camera.
*/
/**
* The active camera's decoded <video>, for a second surface (the panel card)
* to paint from. Null when the active feed is a still or not yet attached.
* @returns {HTMLVideoElement|null}
*/
function getActiveVideoElement() {
if (!layerState._enabled) return null;
return parts.selection.getActiveRecord()?.projection?.video || null;
}
function pauseInactiveProjectionFeeds(activeId) {
for (const record of layerState._records) {
if (!record.projection?.video) continue;
if (
record.camera.id === activeId &&
layerState._enabled &&
layerState._showProjection
) {
if (record.camera.id === activeId && layerState._enabled) {
record.projection.video.play().catch(() => {});
} else {
record.projection.video.pause();
const runtime = record.projection;
destroyProjectionRuntime(runtime);
record.projection = null;
layerState._projectionEntities = layerState._projectionEntities.filter(
(entry) => entry !== runtime,
);
}
}
}
return {
createCctvProjectionOverlayEntry,
getActiveVideoElement,
updatePlanePlacement,
clearProjectionOverlay,
setPlaneVisible,
+6 -1
View File
@@ -215,7 +215,12 @@ export function createRendering({
// (set at creation) — see the field-test far-zoom submerge fix there.
}
if (layerState._enabled && layerState._showProjection && isActive) {
if (
layerState._enabled &&
isActive &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
) {
parts.projection.ensureProjectionRuntime(record);
}
// One live plane in the world at a time (§2c): only the active camera's
+111
View File
@@ -0,0 +1,111 @@
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
url,
feedType,
{
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
: url;
let timer = null;
let startup = null;
let governor = null;
let retries = 0;
const release = () => {
if (feedType === 'hls') {
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
() => {},
);
}
};
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
clearTimeout(startup);
clearInterval(governor);
hls?.destroy();
hls = null;
video.removeEventListener('canplay', play);
video.removeEventListener('error', fail);
video.pause();
video.removeAttribute('src');
video.load();
release();
};
const fail = () => {
if (disposed) return;
dispose();
onFailure();
};
const play = () => {
if (disposed) return;
clearTimeout(startup);
video.play().catch(() => {});
};
video.addEventListener('canplay', play);
video.addEventListener('error', fail);
startup = setTimeout(fail, 30000);
const ready = (async () => {
if (feedType !== 'hls') {
video.src = mediaUrl;
return;
}
try {
const { default: Hls } = await loadHls();
if (disposed) return;
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl'))
video.src = mediaUrl;
else fail();
return;
}
hls = new Hls({
enableWorker: true,
maxBufferLength: 24,
maxMaxBufferLength: 30,
backBufferLength: 0,
maxBufferSize: 16 * 1024 * 1024,
liveSyncDurationCount: 3,
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (disposed || !data?.fatal) return;
if (++retries > 2) {
fail();
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (disposed) return;
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.loadSource(mediaUrl);
}, 2000);
});
governor = setInterval(() => {
if (disposed || !video.buffered?.length) return;
const ahead =
video.buffered.end(video.buffered.length - 1) - video.currentTime;
// Some agency encoders publish less media time than wall time. Bound
// correction rather than repeatedly draining the live buffer at 1x.
video.playbackRate =
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
}, 1000);
hls.attachMedia(video);
hls.loadSource(mediaUrl);
} catch {
fail();
}
})();
return { dispose, ready };
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
function video() {
const v = new EventTarget();
Object.assign(v, {
src: '',
pause() {},
load() {},
removeAttribute() {},
play: () => Promise.resolve(),
});
return v;
}
test('switch during lazy import never starts the stale decoder', async () => {
let resolve;
let constructed = 0;
class Hls {
constructor() {
constructed++;
}
static isSupported() {
return true;
}
}
const source = video();
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: () =>
new Promise((r) => {
resolve = r;
}),
});
playback.dispose();
resolve({ default: Hls });
await playback.ready;
assert.equal(constructed, 0);
assert.equal(source.src, '');
});
test('missing decoder uses honest failure fallback once', async () => {
let failures = 0;
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
loadHls: async () => {
throw new Error('unavailable');
},
onFailure: () => failures++,
});
await playback.ready;
playback.dispose();
assert.equal(failures, 1);
});
test('native HLS releases its client lease without response-header access', async () => {
const source = video();
source.canPlayType = () => 'probably';
const releases = [];
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async (url, init) => {
releases.push({ url, init });
},
});
await playback.ready;
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
const requested = source.src;
playback.dispose();
playback.dispose();
assert.equal(releases.length, 1);
assert.equal(releases[0].url, requested);
assert.equal(releases[0].init.method, 'DELETE');
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
source.loop = feedType === 'hls';
source.canPlayType = () => 'probably';
let imports = 0;
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
loadHls: async () => {
imports++;
return { default: { isSupported: () => false } };
},
fetchImpl: async () => {},
});
try {
await playback.ready;
assert.equal(source.loop, feedType !== 'hls');
assert.equal(imports, feedType === 'hls' ? 1 : 0);
} finally {
playback.dispose();
}
}
});
+447
View File
@@ -0,0 +1,447 @@
import * as Cesium from 'cesium';
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
registerPickOwner,
unregisterPickOwner,
} from '../../data/pickRegistry.js';
import {
createCycloneRendering,
coherentCycloneGeometry,
} from './rendering.js';
import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const CLASSIFICATION_NAMES = Object.freeze({
PTC: 'Potential tropical cyclone',
HU: 'Hurricane',
TS: 'Tropical storm',
TD: 'Tropical depression',
SS: 'Subtropical storm',
SD: 'Subtropical depression',
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
const number = (value, unit) =>
value === null ? 'Unavailable' : `${value} ${unit}`;
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
cesium = Cesium,
createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let viewer = null,
rendering = null,
snapshot = null,
request = null,
listener = null,
selectedId = null,
selectionIntent = 'auto',
navigationGeneration = 0,
clickHandler = null,
removeClickCapture = null;
let enabled = false,
loading = false,
error = null,
destroyed = false,
runNavigation = null;
const notify = () => listener?.();
const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function select(id) {
if (selectedId !== id) ++navigationGeneration;
selectedId = id;
rendering?.setSelection(id);
}
// Photorealistic 3D Tiles pick as tileset content without an entity id;
// that is empty map, the same as no pick at all on the globe.
const isSurfacePick = (picked) =>
!picked ||
(picked.id === undefined &&
(picked.content !== undefined ||
(typeof cesium.Cesium3DTileset === 'function' &&
picked.primitive instanceof cesium.Cesium3DTileset)));
function installSelection() {
if (
clickHandler ||
!viewer?.scene?.canvas ||
typeof cesium.ScreenSpaceEventHandler !== 'function'
)
return;
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler = owner;
// Snapshot before sibling bubble listeners can rebuild the overlay hit
// rectangles. A vessel selection does that synchronously in the same click.
const canvas = viewer.scene.canvas;
let capturedHit = null;
const overlayHit = (x, y) => {
const hit = hitTestOverlay(x, y);
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
};
const capture = (event) => {
capturedHit = null;
const point = event.changedTouches?.[0] || event;
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
return;
const rect = canvas.getBoundingClientRect();
const x = point.clientX - rect.left,
y = point.clientY - rect.top;
capturedHit = { x, y, ...overlayHit(x, y) };
};
const resetCapture = () => {
capturedHit = null;
};
const resetEvents = [
'pointerdown',
'mousedown',
'touchstart',
'pointercancel',
'touchcancel',
];
for (const type of resetEvents)
canvas.addEventListener?.(type, resetCapture, { capture: true });
const events = ['pointerup', 'mouseup', 'touchend'];
for (const type of events)
canvas.addEventListener?.(type, capture, { capture: true });
removeClickCapture = () => {
for (const type of events)
canvas.removeEventListener?.(type, capture, { capture: true });
for (const type of resetEvents)
canvas.removeEventListener?.(type, resetCapture, { capture: true });
capturedHit = null;
};
owner.setInputAction((click) => {
const nativeHit = capturedHit;
capturedHit = null;
// Ambient selection yields to draw tools and Director; it never claims
// the pointer, camera, or tracking state.
if (
!enabled ||
destroyed ||
clickHandler !== owner ||
!isPointerFree() ||
!click?.position
)
return;
// AIS cards paint above the globe on a pointer-events:none canvas. The
// vessel handler resolves this same topmost hit before cyclone geometry.
const captureMatches =
nativeHit &&
Math.abs(nativeHit.x - click.position.x) < 1 &&
Math.abs(nativeHit.y - click.position.y) < 1;
const hit = captureMatches
? nativeHit
: overlayHit(click.position.x, click.position.y);
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
// Storm cards and lead-hour labels paint on the same canvas; a click on
// one selects its storm. An id from a superseded advisory changes nothing.
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
const id = cycloneStormIdFromEntryId(hit.entryId);
if (id) layer.setParams({ stormId: id });
return;
}
const picked = viewer.scene.pick(click.position);
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeSelection() {
const owner = clickHandler;
clickHandler = null;
removeClickCapture?.();
removeClickCapture = null;
if (owner && !owner.isDestroyed?.()) owner.destroy();
}
const layer = {
id: 'weather-cyclones',
name: 'Cyclone advisories',
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({ viewer, cesium, overlayHost });
},
attachShellServices(services) {
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
if (!destroyed && !enabled) {
enabled = true;
registerPickOwner(
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
);
installSelection();
}
},
disable() {
enabled = false;
unregisterPickOwner('weather-cyclones');
removeSelection();
request?.abort();
request = null;
loading = false;
error = null;
snapshot = null;
selectedId = null;
selectionIntent = 'auto';
++navigationGeneration;
rendering?.clear();
},
async update(_viewer, { signal } = {}) {
if (!enabled || destroyed) return false;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const next = await feed.getSnapshot({ signal: controller.signal });
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (next.unavailable) {
// An expired advisory must not remain presented as current hazard context.
rendering.clear();
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || 'Cyclone advisories unavailable';
return true;
}
const applied = await rendering.setSnapshot(next, {
signal: controller.signal,
});
if (
!applied ||
!enabled ||
controller.signal.aborted ||
request !== controller
)
return false;
snapshot = next;
error = null;
if (!snapshot.storms.some((storm) => storm.id === selectedId))
select(
selectionIntent === 'cleared'
? null
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Cyclone advisories unavailable';
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
selectedId = null;
++navigationGeneration;
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = false;
notify();
}
}
},
setParams(params = {}) {
if (!enabled || destroyed) return;
if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared';
select(null);
notify();
} else if (
typeof params.stormId === 'string' &&
snapshot?.storms.some((storm) => storm.id === params.stormId)
) {
selectionIntent = 'user';
select(params.stormId);
notify();
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
const sphere = rendering.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
runNavigation(() => {
if (
!enabled ||
destroyed ||
generation !== navigationGeneration ||
selectedId !== storm.id
)
return;
return viewer.camera.flyToBoundingSphere(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
: 1.4,
});
});
}
}
if (params.advisory === true && storm?.advisoryUrl)
openLink(storm.advisoryUrl);
},
getRowControls() {
const storm = selected();
const empty =
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? 'Track and cone match this advisory'
: storm.geometryStatus === 'pending'
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
: 'Track/cone unavailable');
const status =
error ||
(snapshot?.stale
? 'Cached advisory · stale source'
: loading
? 'Loading advisories…'
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const detail = storm
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
: empty
? 'No active NHC/CPHC systems'
: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
: loading
? 'Loading advisories…'
: 'Advisories unavailable';
const controls = {
readout: true,
summary: {
label: 'Cyclones · NHC / CPHC',
coverage: 'Atlantic · E/C Pacific',
compact: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: 'Official advisory ↗',
href: storm.advisoryUrl,
},
]
: [],
settings: [],
lines: storm
? [
{
id: 'position',
text: `Position as of ${utc(storm.positionAt)}`,
muted: true,
},
{
id: 'intensity',
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
},
{ id: 'geometry', text: geometry, muted: true },
]
: [],
detail,
status,
units: 'kt',
},
list: {
ariaLabel: 'Active NHC and CPHC cyclone advisories',
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
},
chips: [],
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
]
: [],
info: storm
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
infoTitle:
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
};
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
const storm = selected();
return {
count: snapshot?.storms.length || 0,
lastUpdate: storm
? Date.parse(storm.issuedAt)
: snapshot?.fetchedAt || null,
loading,
error,
stale: Boolean(snapshot?.stale),
source: 'NOAA NHC / CPHC',
advisoryAt: storm?.issuedAt || null,
empty: Boolean(
snapshot && !snapshot.unavailable && !snapshot.storms.length,
),
};
},
getDiagnostics() {
return {
...rendering?.getDiagnostics(),
enabled,
loading,
requestPending: !!request,
selectionActive: clickHandler !== null,
selectedId,
selectionIntent,
timerActive: false,
};
},
destroy() {
if (destroyed) return;
layer.disable();
destroyed = true;
rendering?.destroy();
rendering = null;
viewer = null;
listener = null;
runNavigation = null;
},
};
return layer;
}
+673
View File
@@ -0,0 +1,673 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import {
claimPointer,
releasePointer,
isPointerFree,
} from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({
id,
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
});
const snapshot = (storms = [storm()]) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: 'Atlantic and eastern/central North Pacific',
reason: null,
});
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
const handlers = [],
pickedEntity = {},
foreignEntity = {};
let picked = { id: pickedEntity },
overlayHit = null,
overlayTests = 0,
picks = 0,
notices = 0;
const rendering = {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
ownsPickId: (id) => id === 'cyclone:ep162026:center',
getDiagnostics: () => ({}),
};
const cesium = {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor(canvas) {
assert.ok(canvas);
handlers.push(this);
this.destroyed = false;
}
setInputAction(callback, type) {
assert.equal(type, 'left');
this.click = callback;
}
destroy() {
this.destroyed = true;
}
isDestroyed() {
return this.destroyed;
}
},
};
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium,
createRendering: () => rendering,
hitTestOverlay: () => {
overlayTests++;
return overlayHit;
},
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const viewer = {
scene: {
canvas,
pick() {
picks++;
return picked;
},
},
trackedEntity: null,
camera: {
flyToBoundingSphere() {
assert.fail('Click must not move the camera');
},
},
};
layer.init(viewer);
layer.setRowControlsListener(() => notices++);
assert.equal(handlers.length, 0, 'disabled module installs no handler');
layer.enable();
layer.enable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
true,
'flight tracking recognizes the cyclone pick and leaves the camera alone',
);
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
false,
'registration recognizes exact current IDs, not a prefix',
);
assert.equal(handlers.length, 1, 'enable is idempotent');
await layer.update();
const click = { position: { x: 10, y: 20 } };
const owner = claimPointer('director');
assert.ok(owner);
try {
handlers[0].click(click);
assert.equal(picks, 0, 'claimed pointer is checked before picking');
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
} finally {
releasePointer(owner);
}
picked = { id: foreignEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
picked = { primitive: {} };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
picked = { id: pickedEntity };
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
const beforeCard = picks;
handlers[0].click(click);
assert.equal(
picks,
beforeCard,
'foreground AIS card wins before scene picking',
);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A native capture runs before an earlier sibling handler clears hit rects.
const up = new Event('pointerup');
Object.assign(up, { clientX: 110, clientY: 220 });
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'captured AIS hit survives synchronous sibling overlay rebuild',
);
// A captured background hit remains background even if a sibling adds a card.
canvas.dispatchEvent(up);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
// A new gesture clears an unconsumed release snapshot.
canvas.dispatchEvent(up);
canvas.dispatchEvent(new Event('pointerdown'));
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click({ position: { x: 15, y: 25 } });
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
'a different click cannot reuse the captured card',
);
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
const before = notices;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.match(layer.getRowControls().summary.detail, /Another system/);
assert.equal(layer.getRowControls().list.items[1].active, true);
assert.ok(notices > before);
assert.equal(viewer.trackedEntity, null);
picked = undefined;
overlayHit = { sourceId: 'ais-live-vessels' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
picked = { id: pickedEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
picked = { id: 'flight:abc', primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
"another layer's pick leaves the selection alone",
);
picked = { content: {}, primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
null,
'3D Tiles surface content is empty map',
);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(
isPointerFree(),
true,
'ambient selection never claims the pointer',
);
layer.disable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[0].destroyed, true);
assert.equal(layer.getDiagnostics().selectionActive, false);
const afterDisable = picks;
const afterDisableOverlay = overlayTests;
canvas.dispatchEvent(up);
assert.equal(
overlayTests,
afterDisableOverlay,
'disable removes native capture listeners',
);
handlers[0].click(click);
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
layer.enable();
await layer.update();
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
handlers[0].click(click);
assert.equal(
picks,
afterDisable,
'superseded handler remains inert after re-enable',
);
assert.equal(handlers.length, 2);
layer.destroy();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[1].destroyed, true);
});
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
const handlers = [];
let picks = 0,
overlayHit = null,
hostArgument;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium: {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor() {
handlers.push(this);
}
setInputAction(callback) {
this.click = callback;
}
destroy() {}
},
},
overlayHost: 'host',
createRendering: ({ overlayHost }) => {
hostArgument = overlayHost;
return {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: () => null,
ownsPickId: () => false,
getDiagnostics: () => ({}),
};
},
hitTestOverlay: () => overlayHit,
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
layer.init({
scene: {
canvas,
pick() {
picks++;
return { content: {} };
},
},
});
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const click = { position: { x: 10, y: 20 } };
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A card from a superseded advisory is not empty map.
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// The native release snapshot carries the card identity too.
const up = new Event('pointerup');
Object.assign(up, { clientX: 10, clientY: 20 });
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(picks, 0, 'card clicks never fall through to the scene');
handlers[0].click(click);
assert.equal(picks, 1);
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
layer.destroy();
});
function harness(
feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {},
) {
const applied = [],
navigation = [],
opened = [];
let cleared = 0,
destroyed = 0,
selection = null;
const rendering = {
async setSnapshot(value) {
applied.push(value);
return true;
},
setSelection(id) {
selection = id;
},
getFocusSphere: () => ({ radius: 500000 }),
clear() {
cleared++;
},
destroy() {
destroyed++;
},
getDiagnostics: () => ({
entities: applied.at(-1)?.storms.length || 0,
timerActive: false,
}),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
});
const viewer = {
camera: {
flyToBoundingSphere: (sphere, options) =>
navigation.push({ sphere, options }),
},
};
layer.init(viewer);
layer.attachShellServices({
runNavigation: (fn) => {
navigation.push('claimed');
return fn();
},
});
return {
layer,
applied,
navigation,
opened,
get cleared() {
return cleared;
},
get destroyed() {
return destroyed;
},
get selection() {
return selection;
},
};
}
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm('ep162026')]);
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm(), storm('ep162026')]);
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep162026' });
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
assert.equal(h.navigation.length, 0);
next = snapshot([storm()]);
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
for (const clear of [{ stormId: null }, { clear: true }]) {
h.layer.setParams({ stormId: 'ep152026' });
h.layer.setParams(clear);
assert.equal(h.selection, null);
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
await h.layer.update();
assert.equal(h.selection, null);
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
}
h.layer.disable();
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
});
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
const staged = deferred();
const staging = deferred();
let calls = 0;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot() },
createRendering: () => ({
setSnapshot() {
if (++calls === 1) return true;
staging.resolve();
return staged.promise;
},
setSelection() {},
getDiagnostics: () => ({}),
clear() {},
destroy() {},
}),
});
layer.init({});
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const pending = layer.update();
await staging.promise;
layer.setParams({ clear: true });
staged.resolve(true);
await pending;
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
layer.destroy();
});
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
if (change === 'select') {
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep152026' });
} else if (change === 'clear') h.layer.setParams({ stormId: null });
else if (change === 'fallback') {
next = snapshot([storm('ep162026')]);
await h.layer.update();
} else h.layer[change]();
callbacks.shift()();
assert.equal(h.navigation.length, 0, change);
h.layer.destroy();
}
});
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
const h = harness();
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
h.layer.setParams({ stormId: 'ep152026', focus: true });
await h.layer.update();
callbacks[0]();
assert.equal(h.navigation.length, 0);
callbacks[1]();
assert.equal(h.navigation.length, 1);
assert.equal(h.navigation[0].options.duration, 1.4);
h.layer.destroy();
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
const record = { ...storm(), classification: code };
const h = harness({ getSnapshot: async () => snapshot([record]) });
try {
h.layer.enable();
await h.layer.update();
const controls = h.layer.getRowControls();
assert.ok(controls.summary.detail.includes(` · ${label} · `));
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
assert.equal(record.classification, code);
assert.equal(h.applied[0].storms[0].classification, code);
} finally {
h.layer.destroy();
}
}
});
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
const h = harness(
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
{ reducedMotion: true },
);
h.layer.enable();
assert.equal(await h.layer.update(), true);
const controls = h.layer.getRowControls();
assert.equal(controls.readout, true);
assert.deepEqual(controls.chips, []);
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
assert.equal(controls.summary.lines.length, 3);
assert.deepEqual(controls.summary.settings, []);
assert.deepEqual(controls.summary.actions, [
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
]);
assert.match(controls.summary.compact, /2 active storms · .* selected/);
assert.equal(controls.summary.sections, undefined);
assert.match(controls.list.ariaLabel, /NHC/);
assert.equal(controls.list.items.length, 2);
assert.equal(controls.list.items[0].active, true);
assert.match(controls.info, /awaiting advisory 10/);
assert.equal(
controls.info.split('Track/cone awaiting advisory 10').length - 1,
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16 03:00 UTC/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
assert.equal(
controls.chips.some((chip) => chip.id === 'focus'),
false,
);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.selection, 'ep162026');
assert.equal(h.navigation[0], 'claimed');
assert.equal(h.navigation[1].options.duration, 0);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.navigation.length, 4, 'selected row focuses again');
h.layer.setParams({ advisory: true });
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
h.layer.disable();
h.layer.setParams({ focus: true, advisory: true });
assert.equal(h.navigation.length, 4);
assert.equal(h.opened.length, 1);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.layer.getDiagnostics().timerActive, false);
h.layer.destroy();
assert.equal(h.destroyed, 1);
});
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
let next = snapshot([]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().empty, true);
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
next = { ...snapshot(), stale: true };
await h.layer.update();
assert.match(h.layer.getRowControls().summary.status, /stale/);
assert.match(
h.layer.getRowControls().info,
/Track\/cone awaiting advisory 10/,
);
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
next = {
...snapshot([]),
unavailable: true,
reason: 'Cyclone data unavailable',
};
await h.layer.update();
assert.equal(h.layer.getStats().empty, false);
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
const first = deferred(),
second = deferred();
const signals = [];
let calls = 0;
const h = harness({
getSnapshot: ({ signal }) => {
signals.push(signal);
return (++calls === 1 ? first : second).promise;
},
});
h.layer.enable();
const old = h.layer.update(),
latest = h.layer.update();
assert.equal(signals[0].aborted, true);
second.resolve(snapshot());
assert.equal(await latest, true);
first.resolve(snapshot([storm('ep162026')]));
assert.equal(await old, false);
assert.equal(h.applied.length, 1);
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
const delayed = deferred();
const disabled = harness({ getSnapshot: () => delayed.promise });
disabled.layer.enable();
const work = disabled.layer.update();
disabled.layer.disable();
delayed.resolve(snapshot());
assert.equal(await work, false);
assert.equal(disabled.applied.length, 0);
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
disabled.layer.destroy();
});
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => {
if (fail) throw new Error('Network unavailable');
return snapshot();
},
});
h.layer.enable();
assert.equal(
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
false,
);
assert.equal(h.applied.length, 0);
await h.layer.update();
fail = true;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
assert.match(h.layer.getStats().error, /Network/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
+180
View File
@@ -0,0 +1,180 @@
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../../overlays/worldOverlay.js';
/**
* @module cyclones/labels
* @description Storm cards and lead-hour labels on the shared world-overlay
* host. The renderer supplies the anchors its points are drawn and
* horizon-culled at, plus the selection; the host owns projection, the same
* ellipsoid horizon test, collisions, fades and paint.
*/
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
/** Lead-hour labels show within 4,000 km of the camera. */
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
/** Advisory centre and forecast track; single source for points and cards. */
export const CYCLONE_ACCENT = '#7fe6ed';
/** Selected advisory centre. */
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
/** Point sizes the cards keep clear of. */
export const CYCLONE_MARKER_PX = Object.freeze({
storm: 9,
selected: 12,
forecast: 5,
});
const STORM_PRIORITY = 1000;
const LEAD_PRIORITY = 500;
const CYCLONE_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
function markerClearance(markerPx) {
const gapPx = Math.max(12, markerPx + 8);
return {
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
viewportMargin: 4,
};
}
const SHARED_CARD = Object.freeze({
cardStyle: 'tactical',
collisionGroup: 'ambient-card',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// A click on a card selects its storm.
interactive: true,
});
/**
* One card per storm: name, then the source classification code.
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
),
id: `storm:${storm.id}`,
position: storm.position,
variant: selected ? 'selected' : 'card',
title: storm.name,
details: storm.classification ? [storm.classification] : [],
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
selected,
protected: selected,
priority: selected
? Number.MAX_SAFE_INTEGER
: STORM_PRIORITY + (Number(storm.windKt) || 0),
maxDistance: Number.POSITIVE_INFINITY,
};
}
/**
* Lead-hour label at one forecast point of the selected storm. Earlier lead
* times win collisions.
* @param {string} stormId
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
id: `lead:${stormId}:${point.tauHours}`,
position: point.position,
variant: 'card',
title: `${point.tauHours} h`,
details: [],
accent: CYCLONE_ACCENT,
selected: false,
protected: false,
priority: LEAD_PRIORITY - point.tauHours,
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
// Hard cutoff at the limit, without a fade.
distanceFadeStartRatio: 1,
};
}
/**
* Storm cards for every storm and lead-hour labels for the selected one.
* @param {Array<object>} storms Renderer anchors with `forecasts`.
* @param {?string} selectedId
* @returns {Array<object>}
*/
export function cycloneOverlayEntries(storms, selectedId) {
const entries = [];
for (const storm of storms) {
const selected = storm.id === selectedId;
entries.push(cycloneStormEntry(storm, selected));
if (!selected) continue;
for (const point of storm.forecasts || [])
entries.push(cycloneLeadEntry(storm.id, point));
}
return entries;
}
/** Storm id behind a published card or lead-hour label id, else null. */
export function cycloneStormIdFromEntryId(entryId) {
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
return match ? match[1] : null;
}
/**
* Publish the renderer's committed anchors and selection to the overlay host.
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
*/
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
let storms = [];
let selectedId = null;
let published = false;
function publish() {
const entries = cycloneOverlayEntries(storms, selectedId);
let ambient = 0;
for (const entry of entries) if (!entry.protected) ambient++;
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
moving: false,
});
published = true;
}
return {
/** Replace every anchor after a snapshot commits. */
setSnapshot(nextStorms, nextSelectedId = selectedId) {
storms = nextStorms;
selectedId = nextSelectedId;
publish();
},
/** Republish only when the selection changes a published snapshot. */
setSelection(id) {
if (id === selectedId) return;
selectedId = id;
if (published) publish();
},
clear() {
storms = [];
selectedId = null;
if (!published) return;
published = false;
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
},
};
}
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
CYCLONE_OVERLAY_COHORT_LIMIT,
CYCLONE_OVERLAY_SOURCE_ID,
createCycloneLabels,
cycloneOverlayEntries,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
normalizeOverlayEntry,
paintLaneForOverlayEntry,
WORLD_OVERLAY_PAINT_LANES,
} from '../../overlays/worldOverlay.js';
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
const anchor = (longitude, latitude) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
const storms = () => [
{
id: 'al062026',
name: 'Fay',
classification: 'TS',
windKt: 50,
position: anchor(-60, 20),
forecasts: [
{ tauHours: 12, position: anchor(-61, 21) },
{ tauHours: 24, position: anchor(-62, 22) },
],
},
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
windKt: null,
position: anchor(-125, 15),
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
},
];
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
const input = storms();
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
assert.deepEqual(
[...entries.keys()],
[
'storm:al062026',
'lead:al062026:12',
'lead:al062026:24',
'storm:ep152026',
],
'lead-hour labels belong to the selected storm only',
);
const selected = entries.get('storm:al062026');
const ambient = entries.get('storm:ep152026');
for (const card of [selected, ambient]) {
assert.equal(card.cardStyle, 'tactical');
assert.equal(card.collisionGroup, 'ambient-card');
assert.equal(card.verticalOnly, true);
assert.equal(card.horizonCull, true);
assert.equal(card.edgeFade, 'keyhole');
assert.equal(card.interactive, true);
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
}
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
assert.equal(selected.title, 'Fay');
assert.deepEqual(selected.details, ['TS']);
assert.equal(selected.variant, 'selected');
assert.equal(selected.selected, true);
assert.equal(selected.protected, true);
assert.equal(selected.accent, '#ffe19a');
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
assert.equal(selected.gapPx, 20);
assert.equal(selected.leaderOffsetPx, 14);
assert.equal(ambient.title, 'Fifteen-E');
assert.deepEqual(ambient.details, ['PTC']);
assert.equal(ambient.variant, 'card');
assert.equal(ambient.protected, false);
assert.equal(ambient.accent, '#7fe6ed');
assert.equal(ambient.gapPx, 17);
assert.equal(ambient.leaderOffsetPx, 11);
const lead = entries.get('lead:al062026:24');
assert.equal(lead.position, input[0].forecasts[1].position);
assert.equal(lead.title, '24 h');
assert.deepEqual(lead.details, []);
assert.equal(lead.cardStyle, 'tactical');
assert.equal(lead.collisionGroup, 'ambient-card');
assert.equal(lead.protected, false);
assert.equal(lead.interactive, true);
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
assert.equal(lead.gapPx, 13);
assert.equal(lead.leaderOffsetPx, 7);
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
const none = cycloneOverlayEntries(input, null);
assert.deepEqual(
none.map((entry) => [entry.id, entry.variant]),
[
['storm:al062026', 'card'],
['storm:ep152026', 'card'],
],
);
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
});
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
);
const lane = (entry) =>
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
const [selected, lead] = entries;
assert.equal(lane(selected), 'selected');
assert.equal(lane(lead), 'ambient-card');
assert.equal(lane(entries.at(-1)), 'ambient-card');
for (const entry of entries) {
assert.equal(entry.collisionGroup, 'ambient-card');
assert.equal(entry.cardStyle, 'tactical');
assert.equal(entry.horizonCull, true);
}
const range = {
minDistance: lead.minDistance,
maxDistance: lead.maxDistance,
fadeStartRatio: lead.distanceFadeStartRatio,
};
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
assert.equal(
distanceFade(40_000_000, {
minDistance: selected.minDistance,
maxDistance: selected.maxDistance,
fadeStartRatio: selected.distanceFadeStartRatio,
}),
1,
'storm cards have no distance limit',
);
});
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
const calls = [];
const host = {
setEntries: (...args) => calls.push(['entries', ...args]),
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
};
const labels = createCycloneLabels({ host });
labels.setSelection('al062026');
labels.clear();
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
labels.setSnapshot(storms(), 'al062026');
assert.deepEqual(
calls.map(([kind, source]) => [kind, source]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID],
['entries', CYCLONE_OVERLAY_SOURCE_ID],
],
);
assert.equal(calls[0][2], true);
const [, , entries, options] = calls[1];
assert.equal(entries.length, 4);
assert.deepEqual(options, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: 3,
moving: false,
});
calls.length = 0;
labels.setSelection('al062026');
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
labels.setSelection('ep152026');
const published = calls.find(([kind]) => kind === 'entries')[2];
assert.deepEqual(
published.map((entry) => entry.id),
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
);
assert.equal(published[1].variant, 'selected');
calls.length = 0;
labels.clear();
assert.deepEqual(calls, [
['clear', CYCLONE_OVERLAY_SOURCE_ID],
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
]);
labels.clear();
labels.setSelection('al062026');
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
});
test('storm identity resolves from card and lead-hour ids only', () => {
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
});
+314
View File
@@ -0,0 +1,314 @@
import {
CYCLONE_ACCENT,
CYCLONE_MARKER_PX,
CYCLONE_SELECTED_ACCENT,
createCycloneLabels,
} from './labels.js';
/** Only geometry from the displayed status advisory is eligible for rendering. */
export function coherentCycloneGeometry(storm) {
return (
storm.geometryStatus === 'current' &&
storm.geometryAdvisoryNumber === storm.advisoryNumber
);
}
/**
* Static Cesium entities with owned horizon culling; no timers or clock.
* Storm names and lead hours are published to the shared overlay host at the
* same anchors the points use.
*/
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
const labels = createCycloneLabels({ host: overlayHost });
let source = null,
generation = 0,
selected = null,
destroyed = false;
let centers = new Map(),
spheres = new Map();
let entityStorms = new WeakMap();
let entityIds = new Set();
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
let horizonStorms = [],
removePreRender = null,
pointOccluder = null,
sphereOccluder = null;
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
const white = C.Color.WHITE;
const render = () => {
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
};
function cullHorizon() {
pointOccluder.cameraPosition = viewer.camera.positionWC;
sphereOccluder.cameraPosition = viewer.camera.positionWC;
let changed = false;
for (let i = 0; i < horizonStorms.length; i++) {
const storm = horizonStorms[i];
for (let j = 0; j < storm.points.length; j++) {
const { entity, position } = storm.points[j];
const show = pointOccluder.isPointVisible(position);
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
if (!storm.shapes.length) continue;
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
for (let j = 0; j < storm.shapes.length; j++) {
const entity = storm.shapes[j];
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
}
if (changed) render();
}
function syncHorizonListener() {
if (!horizonStorms.length) {
removePreRender?.();
removePreRender = null;
return;
}
if (removePreRender) return;
pointOccluder ||= new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
// conservatively culls extents, retaining partially visible tracks/cones.
sphereOccluder ||= new C.Occluder(
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
viewer.camera.positionWC,
);
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
}
function remove(value) {
if (!value) return;
if (!viewer.dataSources.isDestroyed?.())
viewer.dataSources.remove(value, true);
value.entities.removeAll();
}
function select(id) {
selected = id;
for (const [stormId, entity] of centers) {
entity.point.color = stormId === id ? gold : blue;
entity.point.pixelSize =
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
}
labels.setSelection(id);
render();
}
return {
async setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
if (destroyed) return false;
const owner = ++generation;
const next = new C.CustomDataSource('weather-cyclones');
const nextCenters = new Map(),
nextSpheres = new Map();
const nextEntityStorms = new WeakMap();
const nextEntityIds = new Set();
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
const nextHorizonStorms = [];
const nextLabels = [];
const position = ({ longitude, latitude }) =>
C.Cartesian3.fromDegrees(longitude, latitude, 0);
const coordinate = (pair) =>
position({ longitude: pair[0], latitude: pair[1] });
try {
for (const storm of snapshot.storms) {
const horizon = { points: [], shapes: [], sphere: null };
const addEntity = (options) => {
const entity = next.entities.add(options);
if (options.position)
horizon.points.push({ entity, position: options.position });
else horizon.shapes.push(entity);
nextEntityStorms.set(entity, storm.id);
nextEntityIds.add(entity.id);
return entity;
};
const center = position(storm.position),
extent = [center];
const entity = addEntity({
id: `cyclone:${storm.id}:center`,
name: storm.name,
position: center,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.storm,
color: blue,
outlineColor: C.Color.BLACK,
outlineWidth: 2,
},
});
nextCenters.set(storm.id, entity);
const label = {
id: storm.id,
name: storm.name,
classification: storm.classification,
windKt: storm.windKt,
position: center,
forecasts: [],
};
nextLabels.push(label);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: storm.track?.coordinates || [];
lines.forEach((line, index) => {
const positions = line.map(coordinate);
extent.push(...positions);
addEntity({
id: `cyclone:${storm.id}:track:${index}`,
polyline: {
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
positions,
width: 2.5,
material: blue,
arcType: C.ArcType.GEODESIC,
},
});
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: storm.cone?.coordinates || [];
polygons.forEach((rings, index) => {
// Each exterior retains its own interior holes. Native geographic
// tessellation handles the antimeridian; never flatten rings.
const exterior = rings[0].map(coordinate);
extent.push(...exterior);
const holes = rings
.slice(1)
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
addEntity({
id: `cyclone:${storm.id}:cone:${index}`,
polygon: {
hierarchy: new C.PolygonHierarchy(exterior, holes),
classificationType: C.ClassificationType.BOTH,
material: blue.withAlpha(0.16),
arcType: C.ArcType.GEODESIC,
},
});
[exterior, ...holes.map((hole) => hole.positions)].forEach(
(positions, ringIndex) => {
addEntity({
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
polyline: {
positions,
width: 1,
material: blue.withAlpha(0.55),
arcType: C.ArcType.GEODESIC,
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
},
});
},
);
nextCounts.cones++;
});
for (const [index, point] of storm.forecastPoints.entries()) {
if (point.tauHours === 0) continue;
const p = position(point.position);
extent.push(p);
addEntity({
id: `cyclone:${storm.id}:forecast:${index}`,
position: p,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.forecast,
color: white,
outlineColor: C.Color.BLACK,
outlineWidth: 1,
},
});
label.forecasts.push({ tauHours: point.tauHours, position: p });
nextCounts.forecastPoints++;
}
}
const sphere = C.BoundingSphere.fromPoints(extent);
// A status-only point still has a useful regional camera destination.
sphere.radius = Math.max(sphere.radius, 500_000);
nextSpheres.set(storm.id, sphere);
horizon.sphere = sphere;
nextHorizonStorms.push(horizon);
}
await viewer.dataSources.add(next);
if (destroyed || generation !== owner || signal?.aborted) {
remove(next);
return false;
}
remove(source);
source = next;
centers = nextCenters;
spheres = nextSpheres;
entityStorms = nextEntityStorms;
entityIds = nextEntityIds;
counts = nextCounts;
horizonStorms = nextHorizonStorms;
syncHorizonListener();
labels.setSnapshot(nextLabels, selected);
select(selected);
return true;
} catch (error) {
remove(next);
if (signal?.aborted || generation !== owner || destroyed) return false;
throw error;
}
},
setSelection: select,
ownsPickId(id) {
return source !== null && typeof id === 'string' && entityIds.has(id);
},
pickStorm(picked) {
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
// cannot establish ownership, especially after an advisory replacement.
const entity = picked?.id;
return source && entity && typeof entity === 'object'
? entityStorms.get(entity) || null
: null;
},
getFocusSphere(id) {
return spheres.get(id) || null;
},
clear() {
++generation;
horizonStorms = [];
syncHorizonListener();
pointOccluder = null;
sphereOccluder = null;
remove(source);
source = null;
labels.clear();
centers.clear();
spheres.clear();
entityStorms = new WeakMap();
entityIds.clear();
selected = null;
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
render();
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
},
getDiagnostics() {
return {
...counts,
dataSources: Number(!!source),
entities: source?.entities.values.length || 0,
selectedId: selected,
timerActive: false,
};
},
};
}
+669
View File
@@ -0,0 +1,669 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCycloneRendering } from './rendering.js';
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
/** Records what the renderer publishes to the shared overlay host. */
function overlayRecorder() {
const calls = [];
return {
calls,
host: {
setEntries: (source, entries, options) =>
calls.push({ kind: 'entries', source, entries, options }),
setVisible: (source, visible) =>
calls.push({ kind: 'visible', source, visible }),
clearSource: (source) => calls.push({ kind: 'clear', source }),
},
get publishes() {
return calls.filter(({ kind }) => kind === 'entries').length;
},
published() {
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
},
entry(id) {
return this.published().find((entry) => entry.id === id) || null;
},
};
}
function harness({ deferred = false } = {}) {
const sources = [],
completions = [],
pointOccluders = [],
sphereOccluders = [];
const listeners = new Set();
const visibility = {
points: new Map(),
spheres: new Map(),
pointCalls: [],
sphereCalls: [],
writes: 0,
};
const color = (value) => ({
value,
withAlpha: (alpha) => ({ value, alpha }),
});
const cesium = {
Color: {
fromCssColorString: color,
WHITE: color('white'),
BLACK: color('black'),
},
Cartesian2: class {
constructor(x, y) {
this.x = x;
this.y = y;
}
},
Cartesian3: {
ZERO: { x: 0, y: 0, z: 0 },
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
},
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
EllipsoidalOccluder: class {
constructor(ellipsoid, cameraPosition) {
this.ellipsoid = ellipsoid;
this.cameraPosition = cameraPosition;
pointOccluders.push(this);
}
isPointVisible(position) {
visibility.pointCalls.push(position);
return visibility.points.get(position) ?? true;
}
},
Occluder: class {
constructor(sphere, cameraPosition) {
this.sphere = sphere;
this.cameraPosition = cameraPosition;
sphereOccluders.push(this);
}
isBoundingSphereVisible(sphere) {
visibility.sphereCalls.push(sphere);
return visibility.spheres.get(sphere) ?? true;
}
},
PolygonHierarchy: class {
constructor(positions, holes = []) {
this.positions = positions;
this.holes = holes;
}
},
BoundingSphere: class {
constructor(center, radius) {
this.center = center;
this.radius = radius;
}
static fromPoints(points) {
return { points, radius: 10 };
}
},
LabelStyle: { FILL_AND_OUTLINE: 1 },
HorizontalOrigin: { LEFT: 1 },
ArcType: { GEODESIC: 1 },
HeightReference: Cesium.HeightReference,
ClassificationType: Cesium.ClassificationType,
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
CustomDataSource: class {
constructor() {
const values = [];
this.entities = {
values,
add: (value) => {
let show = true;
Object.defineProperty(value, 'show', {
get: () => show,
set: (next) => {
show = next;
visibility.writes++;
},
});
values.push(value);
return value;
},
removeAll: () => {
values.length = 0;
},
};
}
},
};
let renders = 0;
const overlay = overlayRecorder();
const viewer = {
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
scene: {
requestRender: () => renders++,
preRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
dataSources: {
add(value) {
if (deferred)
return new Promise((resolve) =>
completions.push(() => {
sources.push(value);
resolve(value);
}),
);
sources.push(value);
return Promise.resolve(value);
},
remove(value) {
const i = sources.indexOf(value);
if (i >= 0) sources.splice(i, 1);
},
},
};
return {
rendering: createCycloneRendering({
viewer,
cesium,
overlayHost: overlay.host,
}),
overlay,
sources,
completions,
viewer,
cesium,
visibility,
pointOccluders,
sphereOccluders,
listeners,
frame() {
for (const listener of listeners) listener();
},
get renders() {
return renders;
},
};
}
const storm = () => ({
id: 'ep152026',
name: 'Fifteen-E',
advisoryNumber: '10',
geometryAdvisoryNumber: '10',
geometryStatus: 'current',
position: { longitude: 179, latitude: 15 },
forecastPoints: [
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
],
track: {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
],
},
cone: {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
},
});
test('horizon culling updates only changed entities and keeps selection independent', async () => {
const h = harness();
await h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near' }],
});
const entities = h.sources[0].entities.values;
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
const sphere = h.rendering.getFocusSphere('ep152026');
for (const entity of far)
if (entity.position) h.visibility.points.set(entity.position, false);
h.visibility.spheres.set(sphere, false);
h.rendering.setSelection('ep152026');
const publishes = h.overlay.publishes;
const before = h.renders;
h.frame();
assert.ok(far.every((e) => e.show === false));
assert.ok(near.every((e) => e.show === true));
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
forecast.position,
'the host culls labels at the same anchors the points use',
);
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
assert.equal(h.visibility.pointCalls.length, 4);
assert.deepEqual(h.visibility.sphereCalls, [
sphere,
h.rendering.getFocusSphere('near'),
]);
h.frame();
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
// A visible portion of the extent keeps all tracks/cones shown even when
// the centre and an individual forecast point remain beyond the horizon.
h.visibility.spheres.set(sphere, true);
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
h.frame();
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
assert.equal(h.renders, before + 2);
assert.equal(h.pointOccluders.length, 1);
assert.equal(h.sphereOccluders.length, 1);
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
h.rendering.setSelection('near');
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(
h.overlay.entry('lead:near:12').position,
near.find((e) => e.id.endsWith('forecast:0')).position,
);
h.visibility.points.set(forecast.position, true);
const beforeReveal = h.renders;
const revealPublishes = h.overlay.publishes;
h.frame();
assert.equal(forecast.show, true);
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(h.overlay.publishes, revealPublishes);
assert.equal(far[0].show, false);
assert.equal(h.renders, beforeReveal + 1);
h.rendering.destroy();
});
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
const h = harness();
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
const old = [...h.sources[0].entities.values];
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.frame();
assert.ok(
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
assert.equal(h.listeners.size, 0);
const before = h.renders;
h.frame();
assert.equal(h.renders, before);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.rendering.destroy();
h.rendering.destroy();
assert.equal(h.listeners.size, 0);
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
assert.equal(h.listeners.size, 0);
});
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
for (const method of ['clear', 'destroy']) {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 0);
h.rendering[method]();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.listeners.size, 0);
}
});
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
for (const globeShow of [true, false]) {
const sources = [];
const viewer = {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
scene: {
globe: { show: globeShow },
preRender: new Cesium.Event(),
requestRender() {},
},
dataSources: {
async add(source) {
sources.push(source);
},
remove() {},
},
};
const overlay = overlayRecorder();
const rendering = createCycloneRendering({
viewer,
cesium: Cesium,
overlayHost: overlay.host,
});
const at = (id, longitude) => ({
...storm(),
id,
position: { longitude, latitude: 0 },
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
track: {
type: 'LineString',
coordinates: [
[longitude, 0],
[longitude, 1],
],
},
cone: {
type: 'Polygon',
coordinates: [
[
[longitude, 0],
[longitude + 1, 0],
[longitude, 1],
[longitude, 0],
],
],
},
});
await rendering.setSnapshot({
storms: [at('near', 0), at('far', 180), at('limb', 5)],
});
rendering.setSelection('near');
viewer.scene.preRender.raiseEvent();
const entities = sources[0].entities.values;
for (const entity of entities.filter((e) => e.position)) {
assert.equal(
entity.point.heightReference.getValue(),
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
assert.equal(entity.label, undefined, 'no Cesium label graphics');
const published = overlay.entry(
entity.id.includes(':forecast:')
? `lead:${entity.id.split(':')[1]}:24`
: `storm:${entity.id.split(':')[1]}`,
);
if (entity.id.startsWith('cyclone:near:')) {
assert.ok(
Cesium.Cartesian3.equals(
published.position,
entity.position.getValue(),
),
);
assert.equal(published.horizonCull, true);
}
if (entity.id === 'cyclone:near:forecast:0')
assert.equal(published.maxDistance, 4_000_000);
}
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:near:'))
.every((e) => e.show),
);
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:far:'))
.every((e) => !e.show),
);
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
rendering.destroy();
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
}
});
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const oldEntities = [...h.sources[0].entities.values];
assert.equal(oldEntities.length, 8);
for (const entity of oldEntities) {
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
assert.equal(h.rendering.ownsPickId(entity.id), true);
assert.equal(
h.rendering.pickStorm({ id: { ...entity } }),
null,
'copied ID is not ownership',
);
assert.equal(
h.rendering.pickStorm({ id: entity.id }),
null,
'string prefix is not ownership',
);
}
assert.equal(h.rendering.pickStorm(undefined), null);
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
await h.rendering.setSnapshot({ storms: [storm()] });
for (const entity of oldEntities)
assert.equal(h.rendering.pickStorm({ id: entity }), null);
const current = h.sources[0].entities.values[0];
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
h.rendering.clear();
assert.equal(h.rendering.pickStorm({ id: current }), null);
assert.equal(h.rendering.ownsPickId(current.id), false);
h.rendering.destroy();
});
test('registry IDs switch only when the next coherent data source commits', async () => {
const h = harness({ deferred: true });
const first = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
h.completions.shift()();
await first;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
const next = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'ep162026' }],
});
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
h.completions.shift()();
await next;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
h.rendering.destroy();
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
});
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const entities = h.sources[0].entities.values;
const cones = entities.filter((e) => e.polygon);
assert.equal(cones.length, 2);
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
for (const cone of cones) {
assert.equal(
cone.polygon.classificationType,
h.cesium.ClassificationType.BOTH,
);
assert.equal(cone.polygon.height, undefined);
assert.equal(cone.polygon.extrudedHeight, undefined);
assert.equal(cone.polygon.outline, undefined);
}
const outlines = entities.filter((e) => e.id.includes(':outline:'));
assert.equal(outlines.length, 3);
assert.deepEqual(
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
storm().cone.coordinates.flat(),
);
for (const outline of outlines) {
assert.deepEqual(outline.polyline.material, {
value: '#7fe6ed',
alpha: 0.55,
});
}
for (const { polyline } of entities.filter((e) => e.polyline)) {
assert.equal(polyline.clampToGround, true);
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
assert.ok(polyline.positions.every((p) => p.height === 0));
}
assert.equal(
entities.find((e) => e.polyline).polyline.positions[1].lon,
-179,
);
assert.deepEqual(h.rendering.getDiagnostics(), {
storms: 1,
tracks: 1,
cones: 2,
forecastPoints: 1,
dataSources: 1,
entities: 8,
selectedId: null,
timerActive: false,
});
h.rendering.setSelection('ep152026');
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
entities.find((e) => e.id.endsWith('forecast:0')).position,
);
assert.ok(entities.every((e) => e.label === undefined));
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
h.rendering.destroy();
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().entities, 0);
});
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
for (const changed of [
{ geometryStatus: 'pending' },
{ geometryAdvisoryNumber: '9' },
]) {
const h = harness();
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.rendering.getDiagnostics().tracks, 0);
h.rendering.destroy();
}
});
test('a data-source add settling after disable is removed, without a new owner', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
});
test('superseded asynchronous additions cannot replace newer geometry', async () => {
const h = harness({ deferred: true });
const old = h.rendering.setSnapshot({ storms: [storm()] });
const latest = h.rendering.setSnapshot({ storms: [] });
h.completions[1]();
assert.equal(await latest, true);
h.completions[0]();
assert.equal(await old, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 0);
h.rendering.destroy();
});
test('aborting a pending add retains the prior complete source', async () => {
const h = harness({ deferred: true });
const initial = h.rendering.setSnapshot({ storms: [storm()] });
h.completions.shift()();
await initial;
const controller = new AbortController();
const update = h.rendering.setSnapshot(
{ storms: [] },
{ signal: controller.signal },
);
controller.abort();
h.completions.shift()();
assert.equal(await update, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 1);
h.rendering.destroy();
});
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
});
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
h.completions.shift()();
assert.equal(await pending, true);
const entities = h.sources[0].entities.values;
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
assert.deepEqual(
h.overlay.calls
.slice(0, 2)
.map(({ kind, source, visible }) => [kind, source, visible]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
],
);
assert.deepEqual(
h.overlay.published().map((entry) => entry.id),
['storm:ep152026', 'storm:near'],
'no selection, no lead-hour labels',
);
assert.equal(h.overlay.entry('storm:near').title, 'Near');
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
h.rendering.setSelection('near');
assert.equal(center('near').point.pixelSize, 12);
assert.equal(center('ep152026').point.pixelSize, 9);
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
assert.ok(h.overlay.entry('lead:near:12'));
const publishes = h.overlay.publishes;
h.rendering.setSelection('near');
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
// A refresh republishes the new anchors with the retained selection.
const refresh = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'near', name: 'Near' }],
});
h.completions.shift()();
await refresh;
assert.deepEqual(
h.overlay.published().map((entry) => [entry.id, entry.variant]),
[
['storm:near', 'selected'],
['lead:near:12', 'card'],
],
);
assert.equal(
h.overlay.entry('storm:near').position,
h.sources[0].entities.values[0].position,
);
// A superseded addition never publishes, and clear hides the source.
const stale = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
const afterClear = h.overlay.calls.slice(-2);
assert.deepEqual(afterClear, [
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
]);
h.completions.shift()();
assert.equal(await stale, false);
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
h.rendering.destroy();
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
});
+245
View File
@@ -0,0 +1,245 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > max ||
/[\u0000-\u001f<>]/.test(value)
)
throw malformed();
return value;
};
const number = (value, low, high) => {
if (value === null) return null;
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
return value;
};
const time = (value) => {
if (
typeof value !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw malformed();
return value;
};
const advisory = (value) => {
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
throw malformed();
return value;
};
const position = (value) => {
if (!value || value.longitude === null || value.latitude === null)
throw malformed();
return {
longitude: number(value.longitude, -180, 180),
latitude: number(value.latitude, -90, 90),
};
};
function officialLink(value, outlook = false) {
if (value === null) return null;
const url = new URL(text(value, 256));
if (
url.origin !== 'https://www.nhc.noaa.gov' ||
url.username ||
url.password ||
url.hash
)
throw malformed();
if (
outlook
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
url.pathname + url.search,
)
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
)
throw malformed();
return url.href;
}
function geometry(value, kind, budget) {
if (value === null) return null;
const allowed =
kind === 'track'
? ['LineString', 'MultiLineString']
: ['Polygon', 'MultiPolygon'];
if (!allowed.includes(value?.type)) throw malformed();
const point = (pair) => {
if (
!Array.isArray(pair) ||
pair.length !== 2 ||
pair.some((v) => typeof v !== 'number')
)
throw malformed();
position({ longitude: pair[0], latitude: pair[1] });
if (++budget.coordinates > 25_000) throw malformed();
return [...pair];
};
const list = (items, read, min = 1, max = 128) => {
if (!Array.isArray(items) || items.length < min || items.length > max)
throw malformed();
return items.map(read);
};
const line = (pairs) => list(pairs, point, 2, 10_000);
const ring = (pairs) => {
const result = list(pairs, point, 4, 10_000);
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
throw malformed();
return result;
};
const polygon = (rings) => list(rings, ring);
const coordinates =
value.type === 'LineString'
? line(value.coordinates)
: value.type === 'MultiLineString'
? list(value.coordinates, line)
: value.type === 'Polygon'
? polygon(value.coordinates)
: list(value.coordinates, polygon);
return { type: value.type, coordinates };
}
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
export function validateCycloneSnapshot(value) {
if (
!value ||
value.schemaVersion !== 1 ||
typeof value.stale !== 'boolean' ||
typeof value.unavailable !== 'boolean' ||
!Array.isArray(value.storms) ||
value.storms.length > 32
)
throw malformed();
const seen = new Set(),
budget = { coordinates: 0, points: 0 };
const storms = value.storms.map((raw) => {
if (
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
seen.has(raw.id) ||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
)
throw malformed();
seen.add(raw.id);
const advisoryNumber = advisory(raw.advisoryNumber);
const geometryAdvisoryNumber =
raw.geometryAdvisoryNumber === null
? null
: advisory(raw.geometryAdvisoryNumber);
if (
raw.geometryStatus === 'current' &&
geometryAdvisoryNumber !== advisoryNumber
)
throw malformed();
if (
!Array.isArray(raw.forecastPoints) ||
(budget.points += raw.forecastPoints.length) > 500
)
throw malformed();
const taus = new Set();
const forecastPoints = raw.forecastPoints.map((point) => {
const tauHours = number(point.tauHours, 0, 168);
if (tauHours === null || taus.has(tauHours)) throw malformed();
taus.add(tauHours);
if (++budget.coordinates > 25_000) throw malformed();
return {
position: position(point.position),
tauHours,
windKt: number(point.windKt, 0, 300),
gustKt: number(point.gustKt, 0, 350),
};
});
const track = geometry(raw.track, 'track', budget),
cone = geometry(raw.cone, 'cone', budget);
if (
raw.geometryStatus !== 'current' &&
(track || cone || forecastPoints.length)
)
throw malformed();
if (
raw.geometryStatus === 'current' &&
(!track || !cone || !forecastPoints.length)
)
throw malformed();
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
return {
id: raw.id,
name: text(raw.name, 80),
classification: text(raw.classification, 16),
basin: raw.basin,
position: position(raw.position),
positionAt: time(raw.positionAt),
advisoryNumber,
issuedAt: time(raw.issuedAt),
windKt: number(raw.windKt, 0, 300),
pressureHpa: number(raw.pressureHpa, 800, 1100),
movement: {
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
speedKt: number(raw.movement?.speedKt, 0, 200),
},
advisoryUrl: officialLink(raw.advisoryUrl),
outlookUrl: officialLink(raw.outlookUrl, true),
geometryStatus: raw.geometryStatus,
geometryAdvisoryNumber,
forecastPoints,
track,
cone,
};
});
if (value.unavailable && storms.length) throw malformed();
return {
schemaVersion: 1,
source: text(value.source),
attribution: text(value.attribution, 240),
coverage: text(value.coverage, 240),
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
stale: value.stale,
unavailable: value.unavailable,
reason: value.reason === null ? null : text(value.reason, 240),
storms,
};
}
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
export function createCycloneSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ signal } = {}) {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl('/api/cyclones', {
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Cyclone HTTP ${response.status}`);
}
const result = await readResponseJsonCapped(
response,
CYCLONE_RESPONSE_LIMIT,
controller.signal,
);
controller.signal.throwIfAborted();
return validateCycloneSnapshot(result);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCycloneSource,
validateCycloneSnapshot,
CYCLONE_RESPONSE_LIMIT,
} from './source.js';
const issuedAt = '2026-09-16T03:00:00.000Z';
function fixture() {
return {
schemaVersion: 1,
source: 'NOAA NHC / CPHC',
attribution: 'NOAA/NWS',
coverage: 'Atlantic and eastern/central North Pacific',
fetchedAt: Date.parse(issuedAt),
stale: false,
unavailable: false,
reason: null,
storms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: issuedAt,
issuedAt,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
movement: { directionDegrees: null, speedKt: null },
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
},
],
};
}
function current() {
const value = fixture(),
storm = value.storms[0];
storm.geometryStatus = 'current';
storm.geometryAdvisoryNumber = '10';
storm.forecastPoints = [
{
position: { longitude: 179, latitude: 15 },
tauHours: 12,
windKt: 30,
gustKt: 40,
},
];
storm.track = {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
[
[170, 14],
[179, 15],
],
],
};
storm.cone = {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
};
return value;
}
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
const value = current();
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
value.storms[0].cone.properties = {
html: '<script>',
href: 'https://example.invalid',
};
const result = validateCycloneSnapshot(value);
assert.deepEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
assert.equal(result.storms[0].cone.properties, undefined);
assert.notEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
});
test('successful empty coverage is distinct from unavailable', () => {
const value = fixture();
value.storms = [];
assert.equal(validateCycloneSnapshot(value).unavailable, false);
assert.equal(
validateCycloneSnapshot({
...value,
unavailable: true,
stale: true,
fetchedAt: null,
reason: 'Unavailable',
}).unavailable,
true,
);
assert.throws(
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
/Malformed/,
);
});
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
const changes = [
(storm) => {
storm.geometryAdvisoryNumber = '9';
},
(storm) => {
storm.geometryStatus = 'pending';
},
(storm) => {
storm.position.longitude = 181;
},
(storm) => {
storm.track.coordinates[0][0][0] = NaN;
},
(storm) => {
storm.cone.coordinates[0][0].pop();
},
(storm) => {
storm.forecastPoints.push(storm.forecastPoints[0]);
},
(storm) => {
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
},
(storm) => {
storm.issuedAt = '2026-02-31T00:00:00.000Z';
},
(storm) => {
storm.windKt = 9999;
},
];
for (const change of changes) {
const value = current();
change(value.storms[0]);
assert.throws(() => validateCycloneSnapshot(value));
}
const oversized = current();
oversized.storms[0].track = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () =>
Array.from({ length: 9000 }, () => [1, 2]),
),
};
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
assert.throws(
() =>
validateCycloneSnapshot({
...fixture(),
storms: Array(33).fill(fixture().storms[0]),
}),
/Malformed/,
);
});
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
const calls = [];
const source = createCycloneSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json(fixture());
},
});
assert.equal(calls.length, 0);
await source.getSnapshot();
assert.equal(calls[0].url, '/api/cyclones');
assert.equal(calls[0].options.redirect, 'error');
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls.length, 1);
const oversized = createCycloneSource({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
}),
});
await assert.rejects(oversized.getSnapshot(), /too large/);
});
test('source deadline cancels request and removes timer on completion', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const source = createCycloneSource({
timeoutMs: 10,
fetchImpl: async (_url, options) => {
signal = options.signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot();
const rejected = assert.rejects(pending, /timed out/);
t.mock.timers.tick(11);
await rejected;
assert.equal(signal.aborted, true);
});
+219
View File
@@ -0,0 +1,219 @@
/**
* NASA CMR granule catalog for the HLS products, folded with the VIIRS
* overview days into one candidate list.
*/
import {
CATALOG_DAYS,
PRODUCTS,
coverageFor,
groupGranulesByDay,
mergeCandidates,
utcDay,
validateBox,
viirsCandidates,
} from './model.js';
const CMR_ENDPOINT = 'https://cmr.earthdata.nasa.gov/search';
const DAY_MS = 86_400_000;
const CMR_PAGE_SIZE = 200;
/**
* Most records read per collection. A 1,000 km box can touch hundreds of HLS
* tiles a day; past this cap the newest days (the sort order) are kept and
* the result is `truncated`.
*/
const CMR_MAX_RECORDS = 2000;
const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
export async function fetchCmrPages({
product,
url,
fetchImpl,
signal,
maxRecords = CMR_MAX_RECORDS,
}) {
const granules = [];
let hits = 0;
let cursor = null;
for (;;) {
const init = { signal };
if (cursor) init.headers = { [CMR_SEARCH_AFTER]: cursor };
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
}
function finiteOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function isoOrNull(value) {
return typeof value === 'string' && Number.isFinite(Date.parse(value))
? value
: null;
}
function ringOf(points) {
const ring = points
.map(([lon, lat]) => [finiteOrNull(lon), finiteOrNull(lat)])
.filter(([lon, lat]) => lon !== null && lat !== null);
return ring.length >= 3 ? ring : null;
}
function footprintOf(geometry) {
const polygon = geometry?.GPolygons?.[0]?.Boundary?.Points;
if (Array.isArray(polygon)) {
const ring = ringOf(polygon.map((p) => [p?.Longitude, p?.Latitude]));
if (ring) return ring;
}
const r = geometry?.BoundingRectangles?.[0];
if (!r) return null;
const [w, s, e, n] = [
r.WestBoundingCoordinate,
r.SouthBoundingCoordinate,
r.EastBoundingCoordinate,
r.NorthBoundingCoordinate,
];
const ring = ringOf([
[w, s],
[e, s],
[e, n],
[w, n],
]);
return ring?.length === 4 ? ring : null;
}
/**
* Parse a CMR `granules.umm_json` response. Every field is optional upstream;
* granules without a parseable start time are dropped.
* @param {object} json
* @param {string} product `S30` | `L30`
* @returns {{ granules: Array<object>, hits: number }}
*/
export function parseCmrUmm(json, product) {
const granules = [];
for (const item of Array.isArray(json?.items) ? json.items : []) {
const umm = item?.umm || {};
const meta = item?.meta || {};
const range = umm.TemporalExtent?.RangeDateTime || {};
const timeStart = isoOrNull(range.BeginningDateTime);
if (!timeStart) continue;
const cloud = Array.isArray(umm.AdditionalAttributes)
? umm.AdditionalAttributes.find((a) => a?.Name === 'CLOUD_COVERAGE')
?.Values?.[0]
: null;
granules.push({
id: String(
meta['concept-id'] || umm.GranuleUR || meta['native-id'] || '',
),
product,
timeStart,
timeEnd: isoOrNull(range.EndingDateTime) || timeStart,
cloud: finiteOrNull(cloud),
footprint: footprintOf(
umm.SpatialExtent?.HorizontalSpatialDomain?.Geometry,
),
});
}
return { granules, hits: finiteOrNull(json?.hits) ?? granules.length };
}
/**
* CMR granule search URL for one HLS product over a box and time window.
* @param {{ product: string, box: object, startIso: string, endIso: string }} query
* @returns {string}
*/
export function cmrSearchUrl({ product, box, startIso, endIso }) {
const spec = PRODUCTS[product];
if (!spec?.cmrCollection)
throw new TypeError(`No CMR collection for product: ${product}`);
const result = validateBox(box);
if (!result.ok) throw new TypeError(result.message);
const { west, south, east, north } = result.box;
return (
`${CMR_ENDPOINT}/granules.umm_json` +
`?collection_concept_id=${spec.cmrCollection}` +
`&bounding_box=${west},${south},${east},${north}` +
`&temporal=${startIso},${endIso}` +
`&sort_key=-start_date&page_size=${CMR_PAGE_SIZE}`
);
}
/**
* Search both HLS collections concurrently and fold the results with the
* VIIRS overview days. One product failing still yields the other's days plus
* an `errors` entry; an invalid box or clock throws a TypeError.
* @param {{ box: object, days?: number, now?: Date, fetchImpl?: typeof fetch, signal?: AbortSignal }} request
* @returns {Promise<{ candidates: Array<object>, truncated: boolean, errors: Array<{ product: string, message: string }> }>}
*/
export async function searchHls({
box,
days = CATALOG_DAYS,
now = new Date(),
fetchImpl = globalThis.fetch,
signal,
} = {}) {
const validated = validateBox(box);
if (!validated.ok) throw new TypeError(validated.message);
if (typeof fetchImpl !== 'function')
throw new TypeError('A fetch implementation is required');
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
if (!Number.isFinite(nowMs)) throw new TypeError('A valid clock is required');
const endIso = new Date(nowMs).toISOString();
const startIso = `${utcDay(new Date(nowMs - (days - 1) * DAY_MS))}T00:00:00Z`;
const products = ['S30', 'L30'];
const settled = await Promise.allSettled(
products.map((product) =>
fetchCmrPages({
product,
url: cmrSearchUrl({ product, box: validated.box, startIso, endIso }),
fetchImpl,
signal,
}),
),
);
const granules = [];
const errors = [];
let truncated = false;
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
granules.push(...result.value.granules);
truncated ||= result.value.truncated;
} else {
errors.push({
product: products[index],
message: String(result.reason?.message || result.reason),
});
}
});
const hls = groupGranulesByDay(granules).map((candidate) => ({
...candidate,
coverage: coverageFor(candidate, validated.box),
}));
return {
candidates: mergeCandidates([hls, viirsCandidates(endIso, days)]),
truncated,
errors,
};
}
+296
View File
@@ -0,0 +1,296 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
cmrSearchUrl,
fetchCmrPages,
parseCmrUmm,
searchHls,
} from './catalog.js';
import { BOX, response } from './testDoubles.mjs';
function ummItem({
conceptId = 'G1-LPCLOUD',
begin = '2026-09-18T17:12:01.000Z',
end = '2026-09-18T17:12:31.000Z',
cloud = '12',
polygon = [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
rectangle = null,
} = {}) {
const Geometry = {};
if (polygon)
Geometry.GPolygons = [
{
Boundary: {
Points: polygon.map(([Longitude, Latitude]) => ({
Longitude,
Latitude,
})),
},
},
];
if (rectangle)
Geometry.BoundingRectangles = [
{
WestBoundingCoordinate: rectangle[0],
SouthBoundingCoordinate: rectangle[1],
EastBoundingCoordinate: rectangle[2],
NorthBoundingCoordinate: rectangle[3],
},
];
return {
meta: { 'concept-id': conceptId },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: begin, EndingDateTime: end },
},
AdditionalAttributes: [
{ Name: 'SPATIAL_COVERAGE', Values: ['100'] },
...(cloud === undefined
? []
: [{ Name: 'CLOUD_COVERAGE', Values: [cloud] }]),
],
SpatialExtent: { HorizontalSpatialDomain: { Geometry } },
},
};
}
test('umm_json granules carry id, times, scene cloud and a footprint ring', () => {
const { granules, hits } = parseCmrUmm(
{
hits: 9,
items: [
ummItem(),
ummItem({
conceptId: 'G2',
cloud: '3.5',
polygon: null,
rectangle: [-98.5, 29.5, -97.5, 30.5],
}),
{
meta: {},
umm: {
GranuleUR: 'HLS.L30.only-ur',
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-01T16:00:00Z' },
},
},
},
ummItem({ conceptId: 'G-bad-cloud', cloud: 'n/a' }),
ummItem({
conceptId: 'G-two-points',
polygon: [
[0, 0],
[1, 1],
],
}),
{ meta: { 'concept-id': 'G-no-time' }, umm: {} },
{
meta: { 'concept-id': 'G-bad-time' },
umm: {
TemporalExtent: { RangeDateTime: { BeginningDateTime: 'soon' } },
},
},
null,
],
},
'S30',
);
assert.equal(hits, 9);
assert.deepEqual(granules[0], {
id: 'G1-LPCLOUD',
product: 'S30',
timeStart: '2026-09-18T17:12:01.000Z',
timeEnd: '2026-09-18T17:12:31.000Z',
cloud: 12,
footprint: [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
});
assert.equal(granules[1].cloud, 3.5);
assert.deepEqual(granules[1].footprint, [
[-98.5, 29.5],
[-97.5, 29.5],
[-97.5, 30.5],
[-98.5, 30.5],
]);
assert.deepEqual(
granules.slice(2).map((g) => [g.id, g.cloud, g.footprint]),
[
['HLS.L30.only-ur', null, null],
['G-bad-cloud', null, granules[0].footprint],
['G-two-points', 12, null],
],
'missing fields stay unknown; granules without a start time are dropped',
);
assert.equal(granules[2].timeEnd, '2026-09-01T16:00:00Z');
assert.deepEqual(parseCmrUmm(null, 'S30'), { granules: [], hits: 0 });
});
test('the CMR query names the collection, box, window and page size with literal commas', () => {
assert.equal(
cmrSearchUrl({
product: 'S30',
box: BOX,
startIso: '2026-08-23T00:00:00Z',
endIso: '2026-09-21T12:00:00.000Z',
}),
'https://cmr.earthdata.nasa.gov/search/granules.umm_json?collection_concept_id=C2021957295-LPCLOUD&bounding_box=-97.8,30.2,-97.7,30.3&temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00.000Z&sort_key=-start_date&page_size=200',
);
assert.match(
cmrSearchUrl({ product: 'L30', box: BOX, startIso: 'a', endIso: 'b' }),
/collection_concept_id=C2021957657-LPCLOUD/,
);
assert.throws(
() => cmrSearchUrl({ product: 'VIIRS', box: BOX }),
/No CMR collection/,
);
});
/** Route a CMR request to the S30 or L30 handler by collection id. */
function cmrFetch(handlers, calls = []) {
return async (url, init) => {
calls.push({ url, init });
const handler = handlers[url.includes('C2021957295') ? 'S30' : 'L30'];
return typeof handler === 'function'
? handler()
: response({ json: handler });
};
}
test('both HLS collections fold with the VIIRS days; one failing product is reported, not fatal', async () => {
const calls = [];
const signal = new AbortController().signal;
const result = await searchHls({
box: BOX,
now: new Date('2026-09-21T12:00:00Z'),
signal,
fetchImpl: cmrFetch(
{
S30: {
hits: 2,
items: [
ummItem(),
ummItem({ conceptId: 'G-old', begin: '2026-09-08T17:12:01Z' }),
],
},
L30: () => response({ ok: false, status: 503 }),
},
calls,
),
});
assert.deepEqual(result.errors, [
{ product: 'L30', message: 'HTTP 503 from CMR' },
]);
assert.equal(result.truncated, false);
assert.equal(calls[0].init.signal, signal);
assert.match(
calls[0].url,
/temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00\.000Z/,
'30 UTC days ending now',
);
const keys = result.candidates.map((c) => c.key);
assert.equal(keys.length, 32);
assert.deepEqual(keys.slice(2, 5), [
'VIIRS:2026-09-19',
'S30:2026-09-18',
'VIIRS:2026-09-18',
]);
assert.ok(keys.includes('S30:2026-09-08'));
assert.equal(
result.candidates.find((c) => c.key === 'S30:2026-09-18').coverage,
'full',
'coverage is computed against the box',
);
const thrown = await searchHls({
box: BOX,
days: 2,
now: '2026-09-21T00:00:00Z',
fetchImpl: cmrFetch({
S30: () => {
throw new Error('network down');
},
L30: { hits: 500, items: [ummItem({ conceptId: 'G-L30' })] },
}),
});
assert.deepEqual(thrown.errors, [
{ product: 'S30', message: 'network down' },
]);
assert.equal(thrown.truncated, true, 'CMR has more hits than were read');
assert.deepEqual(
thrown.candidates.map((c) => c.key),
['VIIRS:2026-09-21', 'VIIRS:2026-09-20', 'L30:2026-09-18'],
);
});
test('an invalid box or clock is refused before any request', async () => {
let called = false;
const fetchImpl = async () => {
called = true;
};
await assert.rejects(
searchHls({
box: { west: 179, south: 0, east: -179, north: 1 },
fetchImpl,
}),
/dateline/,
);
await assert.rejects(
searchHls({ box: BOX, now: 'never', fetchImpl }),
/clock/,
);
assert.equal(called, false);
});
test('CMR pages follow the CMR-Search-After cursor up to 2,000 records and truncation stays honest', async () => {
const items = (count) =>
Array.from({ length: count }, (_, i) => ummItem({ conceptId: `G-${i}` }));
const cursors = [];
const paged = await fetchCmrPages({
product: 'S30',
url: 'https://cmr/x',
fetchImpl: async (url, init) => {
const page = cursors.push(init.headers?.['CMR-Search-After'] ?? null);
return response({
headers: { 'CMR-Search-After': `cursor-${page}` },
json: { hits: 450, items: items(page < 3 ? 200 : 50) },
});
},
});
assert.equal(paged.granules.length, 450);
assert.equal(paged.truncated, false);
assert.deepEqual(cursors, [null, 'cursor-1', 'cursor-2']);
let pages = 0;
const capped = await fetchCmrPages({
product: 'L30',
url: 'https://cmr/y',
fetchImpl: async () => {
pages += 1;
return response({
headers: { 'cmr-search-after': 'more' },
json: { hits: 5000, items: items(200) },
});
},
});
assert.equal(pages, 10);
assert.equal(capped.granules.length, 2000);
assert.equal(capped.truncated, true);
const noCursor = await fetchCmrPages({
product: 'S30',
url: 'u',
fetchImpl: async () => response({ json: { hits: 900, items: items(200) } }),
});
assert.equal(noCursor.granules.length, 200);
assert.equal(noCursor.truncated, true);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+613
View File
@@ -0,0 +1,613 @@
/**
* Pure domain model for the Recent Imagery layer: products, the selection box,
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
* no network.
*/
/** How far back the catalog looks, in UTC days. */
export const CATALOG_DAYS = 30;
const MAX_BOX_SIDE_KM = 1000;
/** ZOOM IN lands where the top-down view is this wide, well inside the cap. */
const FIT_VIEW_KM = 400;
const FIT_HEIGHT_KM = Object.freeze({ min: 5, max: 400 });
/** GIBS EPSG:3857 tiles do not exist beyond the Web-Mercator limit. */
const MERCATOR_LAT_LIMIT = 85.0511;
const MAX_CLOUD_FOR_CLEAR = 20;
const PIN_BOX_SIDE_KM = 10;
const WVS_ENDPOINT = 'https://wvs.earthdata.nasa.gov/api/v1/snapshot';
const KM_PER_DEG_LAT = 111.32;
const QUANTUM = 100000;
const DAY_MS = 86_400_000;
const PRODUCT_ORDER = ['S30', 'L30', 'VIIRS'];
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
/**
* Keyless, browser-direct imagery products. HLS products are searched via
* CMR; the VIIRS overview is a daily global mosaic with no granule catalog.
* `source` is the on/off group the row chips toggle.
*/
export const PRODUCTS = Object.freeze({
S30: Object.freeze({
label: 'HLS S30',
name: 'Sentinel-2',
source: 'hls',
sensor: 'Sentinel-2 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957295-LPCLOUD',
overview: false,
}),
L30: Object.freeze({
label: 'HLS L30',
name: 'Landsat 8/9',
source: 'hls',
sensor: 'Landsat 8/9 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957657-LPCLOUD',
overview: false,
}),
VIIRS: Object.freeze({
label: 'VIIRS',
name: 'Daily overview',
source: 'viirs',
sensor: 'VIIRS NOAA-21',
resolutionM: 250,
gibsLayer: 'VIIRS_NOAA21_CorrectedReflectance_TrueColor',
maxLevel: 9,
format: 'jpg',
cmrCollection: null,
overview: true,
}),
});
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const toRadians = (degrees) => (degrees * Math.PI) / 180;
const pad2 = (value) => String(value).padStart(2, '0');
/**
* Coerce a box to `{ west, south, east, north }` with south ≤ north. West and
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
box.west,
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
return {
west,
south: Math.min(south, north),
east,
north: Math.max(south, north),
};
}
/**
* Side lengths of a box in km: the box's own longitude span (never the
* shorter way round the globe) at its mid-latitude, and its latitude span.
* @param {object} box Degrees box.
* @returns {{ width: number, height: number }}
*/
export function boxSideKm(box) {
const normalized = normalizeBox(box);
if (!normalized) return { width: NaN, height: NaN };
const midLat = (normalized.south + normalized.north) / 2;
return {
width:
(normalized.east - normalized.west) *
KM_PER_DEG_LAT *
Math.cos(toRadians(midLat)),
height: (normalized.north - normalized.south) * KM_PER_DEG_LAT,
};
}
const refuse = (reason, message) => ({ ok: false, reason, message });
/**
* Validate a selection box for the catalog and the tile providers.
* @param {object} box Degrees box.
* @returns {{ ok: true, box: object } | { ok: false, reason: 'dateline' | 'polar' | 'degenerate' | 'too-large' | 'invalid', message: string }}
*/
export function validateBox(box) {
const normalized = normalizeBox(box);
if (!normalized) return refuse('invalid', 'Box edges must be finite');
const { west, south, east, north } = normalized;
if (Math.abs(west) > 180 || Math.abs(east) > 180)
return refuse('invalid', 'Longitudes must be within ±180°');
if (west > east) return refuse('dateline', 'Select one side of the dateline');
if (
Math.abs(south) > MERCATOR_LAT_LIMIT ||
Math.abs(north) > MERCATOR_LAT_LIMIT
)
return refuse(
'polar',
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
);
if (east === west || north === south)
return refuse('degenerate', 'Box must enclose an area');
const { width, height } = boxSideKm(normalized);
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
return { ok: true, box: normalized };
}
const formatKm = (km) => Math.round(Number(km) || 0).toLocaleString('en-US');
// One short line each: the panel's hint says what to do about it.
function tooLargeMessage(sideKm) {
return `Box is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/** The refusal for USE VIEW when the camera sees more than the cap allows. */
export function viewTooLargeMessage(sideKm) {
return `View is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/**
* The camera height, in metres, at which a top-down view is `FIT_VIEW_KM`
* wide: the horizontal half-angle is atan(tan(fovy / 2) · width / height).
* Clamped to 5–400 km; without a canvas or FOV, a square 60° view.
* @param {{ width?: number, height?: number, fovy?: number }} view Canvas size in px, vertical FOV in radians.
* @returns {number}
*/
export function fitViewHeightM({ width, height, fovy } = {}) {
const aspect = Number(width) / Number(height);
const valid =
Number.isFinite(aspect) && aspect > 0 && fovy > 0 && fovy < Math.PI;
const halfWidth = valid ? Math.tan(fovy / 2) * aspect : Math.tan(Math.PI / 6);
const km = FIT_VIEW_KM / (2 * halfWidth);
return Math.min(FIT_HEIGHT_KM.max, Math.max(FIT_HEIGHT_KM.min, km)) * 1000;
}
/**
* The centre of a box that does not cross the dateline, in degrees.
* @param {object} box Degrees box.
* @returns {{ lon: number, lat: number } | null}
*/
export function boxCentre(box) {
const normalized = normalizeBox(box);
if (!normalized) return null;
return {
lon: (normalized.west + normalized.east) / 2,
lat: (normalized.south + normalized.north) / 2,
};
}
/**
* A square box centred on a pin, `sideKm` on the ground (the east–west extent
* widens by 1/cos(lat)); null when the result would not validate.
* @param {number} lon Degrees.
* @param {number} lat Degrees.
* @param {number} [sideKm]
* @returns {object | null}
*/
export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
if (!finite(lon) || !finite(lat) || !finite(sideKm) || sideKm <= 0)
return null;
const cosLat = Math.cos(toRadians(lat));
if (cosLat <= 0) return null;
const halfLat = sideKm / 2 / KM_PER_DEG_LAT;
const halfLon = sideKm / 2 / (KM_PER_DEG_LAT * cosLat);
const clampLat = (value) =>
Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, value));
const result = validateBox({
west: lon - halfLon,
south: clampLat(lat - halfLat),
east: lon + halfLon,
north: clampLat(lat + halfLat),
});
return result.ok ? result.box : null;
}
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
});
}
/** Degrees box from a radians rectangle (Cesium `Rectangle` shape). */
export function boxFromRectangle(rectangle) {
return mapBox(rectangle, (radians) => (Number(radians) * 180) / Math.PI);
}
/** Degrees box → signed integers at degrees × 100000 (the share-link form). */
export function quantizeBox(box) {
return mapBox(normalizeBox(box), (value) => Math.round(value * QUANTUM));
}
/** Inverse of `quantizeBox`. */
export function dequantizeBox(ints) {
return mapBox(ints, (value) => Number(value) / QUANTUM);
}
function isValidDay(day) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
if (!match) return false;
const time = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
);
return (
Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day
);
}
/**
* UTC calendar day (`YYYY-MM-DD`) of an ISO timestamp, Date or day string;
* null when unparseable or not a real day.
* @param {string | number | Date} value
* @returns {string | null}
*/
export function utcDay(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
return isValidDay(value) ? value : null;
const time = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: null;
}
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
return match ? `${MONTHS[Number(match[1]) - 1]} ${Number(match[2])}` : '';
}
/** `17:12` (UTC) for an ISO timestamp; '' when unparseable. */
export function hhmm(iso) {
const time = Date.parse(iso);
if (!Number.isFinite(time)) return '';
const date = new Date(time);
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
}
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
export function candidateKey(product, day) {
return `${product}:${day}`;
}
/** Split a candidate key; null when the product is unknown or the day invalid. */
export function parseCandidateKey(key) {
const match = /^(S30|L30|VIIRS):(\d{4}-\d{2}-\d{2})$/.exec(String(key));
if (!match || !isValidDay(match[2])) return null;
return { product: match[1], day: match[2] };
}
const cloudOf = (granule) => (finite(granule?.cloud) ? granule.cloud : null);
function buildCandidate(product, day, granules) {
const clouds = granules.map(cloudOf).filter((value) => value !== null);
const starts = granules
.map((granule) => Date.parse(granule.timeStart))
.filter(Number.isFinite);
const ends = granules
.map((granule) => Date.parse(granule.timeEnd ?? granule.timeStart))
.filter(Number.isFinite);
return {
key: candidateKey(product, day),
product,
day,
granules,
cloud: clouds.length
? { min: Math.min(...clouds), max: Math.max(...clouds) }
: null,
timeRange:
starts.length && ends.length
? {
start: new Date(Math.min(...starts)).toISOString(),
end: new Date(Math.max(...ends)).toISOString(),
}
: null,
availability: granules.length ? 'present' : 'empty',
coverage: 'unknown',
};
}
/**
* Group granules into one candidate per product and UTC day of `timeStart`,
* newest first. Granules without a known product or start time are dropped.
* @param {Array<object>} granules
* @returns {Array<object>}
*/
export function groupGranulesByDay(granules) {
const buckets = new Map();
for (const granule of Array.isArray(granules) ? granules : []) {
const day = utcDay(granule?.timeStart);
if (!PRODUCTS[granule?.product] || !day) continue;
const key = candidateKey(granule.product, day);
if (!buckets.has(key))
buckets.set(key, { product: granule.product, day, granules: [] });
buckets.get(key).granules.push(granule);
}
return mergeCandidates([
[...buckets.values()].map(({ product, day, granules: list }) =>
buildCandidate(product, day, list),
),
]);
}
/**
* One VIIRS overview candidate per UTC day for the last `days` days, today
* first. Availability stays unknown until the thumbnail probe answers;
* coverage is full because the overview is a global mosaic.
* @param {string | Date} todayIso
* @param {number} [days]
* @returns {Array<object>}
*/
export function viirsCandidates(todayIso, days = CATALOG_DAYS) {
const today = utcDay(todayIso);
if (!today) return [];
const start = Date.parse(`${today}T00:00:00Z`);
const out = [];
for (let i = 0; i < Math.max(0, Math.trunc(days)); i += 1) {
const day = new Date(start - i * DAY_MS).toISOString().slice(0, 10);
out.push({
key: candidateKey('VIIRS', day),
product: 'VIIRS',
day,
granules: [],
cloud: null,
timeRange: null,
availability: 'unknown',
coverage: 'full',
});
}
return out;
}
/**
* Merge candidate lists newest day first, then S30, L30, VIIRS; the first
* occurrence of a key wins.
* @param {Array<Array<object>>} lists
* @returns {Array<object>}
*/
export function mergeCandidates(lists) {
const byKey = new Map();
for (const list of lists) {
for (const candidate of list || []) {
if (candidate?.key && !byKey.has(candidate.key))
byKey.set(candidate.key, candidate);
}
}
return [...byKey.values()].sort((a, b) =>
a.day !== b.day
? a.day < b.day
? 1
: -1
: PRODUCT_ORDER.indexOf(a.product) - PRODUCT_ORDER.indexOf(b.product),
);
}
function pointInPolygon(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const crosses =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi;
if (crosses) inside = !inside;
}
return inside;
}
/**
* How much of the box a candidate's granule footprints cover, sampled at the
* four corners and the centre: `full`, `partial`, or `unknown` without
* footprints.
* @param {object} candidate
* @param {object} box Degrees box.
* @returns {'full' | 'partial' | 'unknown'}
*/
export function coverageFor(candidate, box) {
const normalized = normalizeBox(box);
const footprints = (candidate?.granules || [])
.map((granule) => granule?.footprint)
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
if (!normalized || !footprints.length) return 'unknown';
const { west, south, east, north } = normalized;
const samples = [
[west, south],
[east, south],
[east, north],
[west, north],
[(west + east) / 2, (south + north) / 2],
];
return samples.every(([lon, lat]) =>
footprints.some((ring) => pointInPolygon(lon, lat, ring)),
)
? 'full'
: 'partial';
}
/**
* Pick the START HERE day. Coverage first: a present HLS day that covers the
* whole box (`full`, or `unknown` without footprints) beats a newer sliver at
* the box edge, which changes none of the frame when draped. Within a tier
* the newest day whose granules ALL have known cloud ≤ `maxCloud` wins
* (`clear`), else the newest (`cloudy`); only slivers left → `partial`; else
* the newest non-empty VIIRS day (`overview`). `certain` is false when the
* catalog was truncated.
* @param {Array<object>} candidates
* @param {{ maxCloud?: number, truncated?: boolean }} [options]
* @returns {{ candidate: object | null, reason: 'clear' | 'cloudy' | 'partial' | 'overview' | null, certain: boolean }}
*/
export function rankLatest(
candidates,
{ maxCloud = MAX_CLOUD_FOR_CLEAR, truncated = false } = {},
) {
const sorted = mergeCandidates([candidates]);
const certain = !truncated;
const present = (c) => c.availability !== 'empty';
const hlsDay = (c) =>
PRODUCTS[c.product] &&
!PRODUCTS[c.product].overview &&
present(c) &&
c.granules.length > 0;
const coversBox = (c) => c.coverage !== 'partial';
const clearSky = (c) =>
c.granules.every((granule) => {
const cloud = cloudOf(granule);
return cloud !== null && cloud <= maxCloud;
});
const tiers = [
[(c) => hlsDay(c) && coversBox(c) && clearSky(c), 'clear'],
[(c) => hlsDay(c) && coversBox(c), 'cloudy'],
[(c) => hlsDay(c) && clearSky(c), 'partial'],
[hlsDay, 'partial'],
[(c) => c.product === 'VIIRS' && present(c), 'overview'],
];
for (const [matches, reason] of tiers) {
const candidate = sorted.find(matches);
if (candidate) return { candidate, reason, certain };
}
return { candidate: null, reason: null, certain };
}
function daysAgoLabel(day, now) {
const today = utcDay(now);
if (!today) return null;
const delta = Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
if (delta <= 0) return 'today';
return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
if (!cloud) return 'cloud unknown';
const min = Math.round(cloud.min);
const max = Math.round(cloud.max);
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
* @returns {string}
*/
export function formatCandidateReadout(candidate, now = new Date()) {
const product = PRODUCTS[candidate?.product];
if (!product || !candidate?.day) return '';
const [y, m, d] = candidate.day.split('-').map(Number);
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
when +=
(candidate.granules?.length || 0) > 1 && end !== start
? ` ${start}–${end}Z`
: ` ${start}Z`;
}
const parts = [when];
const ago = daysAgoLabel(candidate.day, now);
if (ago) parts.push(ago);
parts.push(product.sensor, `${product.resolutionM} m`);
if (product.overview) parts.push('overview');
parts.push(cloudLabel(candidate.cloud));
return parts.join(' · ');
}
/**
* GIBS WMTS REST template with `{s}`, `{z}`, `{y}`, `{x}` placeholders (y
* precedes x in the GIBS path).
* @param {string} product
* @param {string} day `YYYY-MM-DD`
* @returns {string}
*/
export function gibsTemplate(product, day) {
const spec = PRODUCTS[product];
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
return `https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/${spec.gibsLayer}/default/${day}/GoogleMapsCompatible_Level${spec.maxLevel}/{z}/{y}/{x}.${spec.format}`;
}
/**
* Worldview Snapshots URL for a thumbnail or export. EPSG:4326 BBOX order is
* lat,lon: `south,west,north,east`.
* @param {{ product: string, day: string, box: object, width: number, height: number, format?: string }} request
* @returns {string}
*/
export function wvsSnapshotUrl({
product,
day,
box,
width,
height,
format = 'image/png',
}) {
const spec = PRODUCTS[product];
const normalized = normalizeBox(box);
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
if (!normalized) throw new TypeError('A finite box is required');
const bbox = [
normalized.south,
normalized.west,
normalized.north,
normalized.east,
]
.map((value) => String(Number(value.toFixed(6))))
.join(',');
return (
`${WVS_ENDPOINT}?REQUEST=GetSnapshot&LAYERS=${spec.gibsLayer}` +
`&CRS=EPSG:4326&TIME=${day}&BBOX=${bbox}` +
`&WIDTH=${Math.round(width)}&HEIGHT=${Math.round(height)}&FORMAT=${format}`
);
}
/**
* Thumbnail load order: the focused card (clamped into the visible range),
* the visible cards outward from it, then `extra` cards beyond each edge.
* @param {number} focusIndex
* @param {number} firstVisible
* @param {number} lastVisible
* @param {number} total
* @param {number} [extra]
* @returns {number[]}
*/
export function thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
total,
extra = 2,
) {
const count = Math.max(0, Math.trunc(total));
if (!count) return [];
const clamp = (value) => Math.max(0, Math.min(count - 1, Math.trunc(value)));
const first = clamp(Math.min(firstVisible, lastVisible));
const last = clamp(Math.max(firstVisible, lastVisible));
const focus = Math.max(first, Math.min(last, clamp(focusIndex)));
const order = [];
const push = (index) => {
if (index >= 0 && index < count && !order.includes(index))
order.push(index);
};
push(focus);
for (let step = 1; step <= last - first; step += 1) {
if (focus + step <= last) push(focus + step);
if (focus - step >= first) push(focus - step);
}
for (let step = 1; step <= Math.max(0, Math.trunc(extra)); step += 1) {
push(last + step);
push(first - step);
}
return order;
}
+439
View File
@@ -0,0 +1,439 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxFromPin,
boxFromRectangle,
boxSideKm,
coverageFor,
boxCentre,
dequantizeBox,
fitViewHeightM,
formatCandidateReadout,
gibsTemplate,
groupGranulesByDay,
mergeCandidates,
parseCandidateKey,
quantizeBox,
rankLatest,
shortDay,
thumbnailOrder,
utcDay,
validateBox,
viewTooLargeMessage,
viirsCandidates,
wvsSnapshotUrl,
} from './model.js';
import { BOX } from './testDoubles.mjs';
function granule(overrides = {}) {
return {
id: 'g',
product: 'S30',
timeStart: '2026-09-18T17:12:00.000Z',
timeEnd: '2026-09-18T17:12:30.000Z',
cloud: 12,
footprint: null,
...overrides,
};
}
function hls(product, day, clouds, extra = {}) {
const granules = clouds.map((cloud, index) =>
granule({
id: `${product}-${day}-${index}`,
product,
timeStart: `${day}T17:1${index}:00.000Z`,
timeEnd: `${day}T17:1${index}:30.000Z`,
cloud,
}),
);
return { ...groupGranulesByDay(granules)[0], ...extra };
}
test('validateBox refuses dateline, polar, degenerate, invalid and oversized boxes', () => {
const reason = (box) => validateBox(box).reason;
assert.equal(
reason({ west: 179, south: 0, east: -179, north: 1 }),
'dateline',
);
assert.equal(
reason({ west: 0, south: 84, east: 0.5, north: 85.05111 }),
'polar',
);
assert.equal(
reason({ west: 0, south: -85.0512, east: 0.5, north: -84 }),
'polar',
);
// Refusals fit the one-line notice.
assert.equal(
validateBox({ west: 0, south: 84, east: 0.5, north: 85.05111 }).message,
'Imagery stops at ±85.05° latitude',
);
assert.equal(
validateBox({ west: 179, south: 0, east: -179, north: 1 }).message,
'Select one side of the dateline',
);
assert.equal(
validateBox({ west: 0, south: 84.9, east: 0.05, north: 85 }).ok,
true,
);
assert.equal(
reason({ west: 10, south: 10, east: 10, north: 11 }),
'degenerate',
);
assert.equal(
reason({ west: Infinity, south: 0, east: 1, north: 1 }),
'invalid',
);
assert.equal(reason({ west: -181, south: 0, east: 1, north: 1 }), 'invalid');
assert.equal(reason(undefined), 'invalid');
// The cap is 1,000 km a side, and the refusal says how big the box is.
const tall = validateBox({ west: 0, south: 0, east: 0.1, north: 10 });
assert.equal(tall.reason, 'too-large');
assert.equal(tall.message, 'Box is 1,113 km wide · limit 1,000 km');
assert.equal(
reason({ west: 0, south: 0, east: 10, north: 0.1 }),
'too-large',
);
assert.equal(validateBox({ west: 0, south: 0, east: 8, north: 8 }).ok, true);
assert.equal(
viewTooLargeMessage(2400),
'View is 2,400 km wide · limit 1,000 km',
);
// Latitudes are ordered; longitudes are never swapped.
assert.deepEqual(
validateBox({ west: -97.8, south: 30.3, east: -97.7, north: 30.2 }).box,
BOX,
);
});
test('ZOOM IN: the height whose top-down view is 400 km wide, clamped to 5–400 km; a box centre', () => {
const near = (actual, expected, label) =>
assert.ok(Math.abs(actual - expected) < 1e-6, `${label}: ${actual}`);
// A 1000 × 500 canvas with Cesium's 60° horizontal FOV: tan(fovy / 2) is
// tan(30°) / 2, so the view is 2 · h · tan(30°) wide.
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2);
const height = fitViewHeightM({ width: 1000, height: 500, fovy });
near(height, 400_000 / (2 * Math.tan(Math.PI / 6)), 'wide canvas');
near(
2 * height * Math.tan(fovy / 2) * 2,
400_000,
'the view at that height is 400 km wide',
);
// A tall canvas would need more than 400 km; a very wide one less than 5.
assert.equal(
fitViewHeightM({ width: 200, height: 1000, fovy: Math.PI / 3 }),
400_000,
);
assert.equal(fitViewHeightM({ width: 1000, height: 500, fovy: 3.1 }), 5_000);
// No canvas or FOV: a square 60° view.
near(
fitViewHeightM({ width: 0, height: 0, fovy: undefined }),
400_000 / (2 * Math.tan(Math.PI / 6)),
'fallback',
);
assert.deepEqual(boxCentre({ west: 0, south: 0, east: 20, north: 10 }), {
lon: 10,
lat: 5,
});
assert.equal(boxCentre(null), null);
});
test('box width is the longitude span at the mid-latitude, never the shortest arc', () => {
const equator = boxSideKm({ west: 0, south: -0.5, east: 1, north: 0.5 });
assert.ok(Math.abs(equator.width - 111.32) < 1e-6);
assert.ok(Math.abs(equator.height - 111.32) < 1e-6);
const north = boxSideKm({ west: 0, south: 59.5, east: 1, north: 60.5 });
assert.ok(north.width > 55 && north.width < 56.5, String(north.width));
const nearlyGlobal = { west: -179.9, south: 30, east: 179.9, north: 30.1 };
assert.ok(boxSideKm(nearlyGlobal).width > 30000);
assert.equal(validateBox(nearlyGlobal).reason, 'too-large');
});
test('boxFromPin builds a 10 km square within 2% at any latitude and refuses what cannot validate', () => {
for (const [lon, lat] of [
[-97.75, 30.27],
[18.07, 59.33],
[0, 0],
[-70, -45],
]) {
const box = boxFromPin(lon, lat);
const { width, height } = boxSideKm(box);
assert.ok(Math.abs(width - 10) < 0.2, `width ${width} at ${lat}`);
assert.ok(Math.abs(height - 10) < 0.2, `height ${height} at ${lat}`);
assert.ok(Math.abs((box.west + box.east) / 2 - lon) < 1e-9);
}
assert.ok(Math.abs(boxSideKm(boxFromPin(10, 10, 50)).width - 50) < 1);
assert.equal(boxFromPin(NaN, 0), null);
assert.equal(boxFromPin(0, 0, 5000), null, 'over the cap');
assert.equal(boxFromPin(179.99, 0), null, 'would cross the dateline');
assert.equal(boxFromPin(0, 89), null, 'clamped to a degenerate polar box');
assert.equal(boxFromPin(0, 85.04).north, 85.0511, 'clamped, still valid');
});
test('rectangles convert from radians and the share-link quantization round-trips', () => {
const box = boxFromRectangle({
west: -Math.PI / 2,
south: 0,
east: -Math.PI / 4,
north: Math.PI / 6,
});
assert.ok(Math.abs(box.west + 90) < 1e-9 && Math.abs(box.north - 30) < 1e-9);
assert.equal(boxFromRectangle(null), null);
const ints = quantizeBox({
west: -97.812345678,
south: 30.2,
east: -97.7,
north: 30.3999999,
});
assert.deepEqual(ints, {
west: -9781235,
south: 3020000,
east: -9770000,
north: 3040000,
});
assert.deepEqual(quantizeBox(dequantizeBox(ints)), ints);
assert.equal(quantizeBox(null), null);
assert.equal(dequantizeBox({ west: 'x' }), null);
});
test('days and candidate keys are real UTC calendar days', () => {
assert.equal(utcDay('2026-09-18T20:00:00-05:00'), '2026-09-19');
assert.equal(utcDay(new Date(Date.UTC(2026, 0, 1))), '2026-01-01');
assert.equal(utcDay('2026-02-30'), null);
assert.equal(utcDay('nope'), null);
assert.deepEqual(parseCandidateKey('L30:2026-09-10'), {
product: 'L30',
day: '2026-09-10',
});
assert.equal(parseCandidateKey('X30:2026-09-10'), null);
assert.equal(parseCandidateKey('S30:2026-02-30'), null);
assert.equal(shortDay('2026-09-18'), 'Sep 18');
assert.equal(shortDay('nope'), '');
});
test('granules group into one candidate per product and UTC day, newest first', () => {
const candidates = groupGranulesByDay([
granule({ id: 'a', cloud: 12, timeEnd: '2026-09-18T17:12:40Z' }),
granule({
id: 'b',
cloud: 18,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:40Z',
}),
granule({
id: 'c',
cloud: null,
product: 'L30',
timeStart: '2026-09-18T16:00:00Z',
timeEnd: null,
}),
granule({ id: 'd', cloud: 3, timeStart: '2026-09-10T17:00:00Z' }),
granule({ id: 'e', timeStart: 'garbage' }),
granule({ id: 'f', product: 'nope' }),
]);
assert.deepEqual(
candidates.map((c) => c.key),
['S30:2026-09-18', 'L30:2026-09-18', 'S30:2026-09-10'],
);
const [s18, l18] = candidates;
assert.deepEqual(s18.cloud, { min: 12, max: 18 });
assert.deepEqual(s18.timeRange, {
start: '2026-09-18T17:12:00.000Z',
end: '2026-09-18T17:20:40.000Z',
});
assert.equal(s18.availability, 'present');
assert.equal(l18.cloud, null, 'missing cloud stays unknown');
assert.equal(l18.timeRange.end, '2026-09-18T16:00:00.000Z');
});
test('VIIRS lists the last 30 UTC days as unprobed full-coverage days', () => {
const days = viirsCandidates('2026-03-01T13:00:00Z', 3);
assert.deepEqual(
days.map((c) => c.key),
['VIIRS:2026-03-01', 'VIIRS:2026-02-28', 'VIIRS:2026-02-27'],
);
assert.equal(days[0].availability, 'unknown');
assert.equal(days[0].coverage, 'full');
assert.equal(viirsCandidates('2026-09-21').length, 30);
assert.deepEqual(viirsCandidates('bad'), []);
});
test('merging sorts newest day first, then S30, L30, VIIRS, and the first key wins', () => {
const merged = mergeCandidates([
viirsCandidates('2026-09-18', 2),
[hls('L30', '2026-09-18', [5]), hls('S30', '2026-09-17', [5])],
[hls('S30', '2026-09-18', [7]), hls('S30', '2026-09-18', [99])],
]);
assert.deepEqual(
merged.map((c) => c.key),
[
'S30:2026-09-18',
'L30:2026-09-18',
'VIIRS:2026-09-18',
'S30:2026-09-17',
'VIIRS:2026-09-17',
],
);
assert.deepEqual(merged[0].cloud, { min: 7, max: 7 });
});
test('coverage samples the box corners and centre against the union of footprints', () => {
const ring = (west, east) => [
[west, 30],
[east, 30],
[east, 31],
[west, 31],
];
const withRings = (...rings) => ({
granules: rings.map((footprint) => granule({ footprint })),
});
assert.equal(coverageFor(withRings(ring(-98, -97)), BOX), 'full');
assert.equal(coverageFor(withRings(ring(-98, -97.75)), BOX), 'partial');
assert.equal(
coverageFor(withRings(ring(-98, -97.75), ring(-97.75, -97)), BOX),
'full',
);
assert.equal(coverageFor(withRings(null), BOX), 'unknown');
});
test('START HERE is the newest all-clear HLS day; unknown cloud is never clear', () => {
const candidates = [
hls('S30', '2026-09-20', [10, null]),
hls('S30', '2026-09-19', [5, 21]),
hls('S30', '2026-09-18', [0, 20]),
];
assert.deepEqual(rankLatest([...candidates].reverse()), {
candidate: candidates[2],
reason: 'clear',
certain: true,
});
assert.equal(rankLatest(candidates, { truncated: true }).certain, false);
// Then the newest cloudy HLS day, then the newest non-empty VIIRS day.
const cloudy = rankLatest([
hls('L30', '2026-09-20', [45]),
hls('S30', '2026-09-19', [null]),
]);
assert.equal(cloudy.candidate.key, 'L30:2026-09-20');
assert.equal(cloudy.reason, 'cloudy');
const viirs = viirsCandidates('2026-09-21', 3);
viirs[0] = { ...viirs[0], availability: 'empty' };
const empty = { ...hls('S30', '2026-09-21', [0]), availability: 'empty' };
const overview = rankLatest([empty, ...viirs]);
assert.equal(overview.candidate.key, 'VIIRS:2026-09-20');
assert.equal(overview.reason, 'overview');
assert.equal(rankLatest([]).candidate, null);
});
test('START HERE prefers a day that covers the whole box over a newer sliver', () => {
const full = (day, clouds) => hls('S30', day, clouds, { coverage: 'full' });
const sliver = (day, clouds) =>
hls('S30', day, clouds, { coverage: 'partial' });
const pick = (list) => {
const { candidate, reason } = rankLatest(list);
return `${candidate?.day} ${reason}`;
};
assert.equal(
pick([sliver('2026-09-20', [0]), full('2026-09-18', [10])]),
'2026-09-18 clear',
);
assert.equal(
pick([full('2026-09-20', [null]), sliver('2026-09-19', [0])]),
'2026-09-20 cloudy',
);
// Unknown coverage (no footprints) counts as whole-box.
assert.equal(
pick([
hls('S30', '2026-09-20', [5], { coverage: 'unknown' }),
full('2026-09-18', [5]),
]),
'2026-09-20 clear',
);
// Only slivers: the newest clear one, then the newest at all.
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [0])]),
'2026-09-19 partial',
);
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [null])]),
'2026-09-20 partial',
);
assert.equal(
pick([...viirsCandidates('2026-09-21', 2), sliver('2026-09-19', [70])]),
'2026-09-19 partial',
'a sliver still beats the overview',
);
});
test('the readout is one UTC line with age, sensor, resolution and scene cloud', () => {
const now = '2026-09-21T12:00:00Z';
const readout = (granules) =>
formatCandidateReadout(groupGranulesByDay(granules)[0], now);
assert.equal(
readout([granule({ timeStart: '2026-09-18T17:12:10Z' })]),
'Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud',
);
assert.equal(
readout([
granule({ id: 'a' }),
granule({
id: 'b',
cloud: 18.4,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:30Z',
}),
]),
'Sep 18, 2026 17:12–17:20Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12–18% scene cloud',
);
assert.equal(
readout([
granule({
product: 'L30',
cloud: null,
timeStart: '2026-09-20T16:05:00Z',
timeEnd: null,
}),
]),
'Sep 20, 2026 16:05Z · yesterday · Landsat 8/9 via HLS · 30 m · cloud unknown',
);
assert.equal(
formatCandidateReadout(viirsCandidates('2026-09-21', 1)[0], now),
'Sep 21, 2026 · today · VIIRS NOAA-21 · 250 m · overview · cloud unknown',
);
assert.equal(formatCandidateReadout(null), '');
});
test('GIBS templates put y before x; Worldview snapshots take south,west,north,east', () => {
assert.equal(
gibsTemplate('S30', '2026-09-18'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/HLS_S30_Nadir_BRDF_Adjusted_Reflectance/default/2026-09-18/GoogleMapsCompatible_Level12/{z}/{y}/{x}.png',
);
assert.equal(
gibsTemplate('VIIRS', '2026-09-21'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA21_CorrectedReflectance_TrueColor/default/2026-09-21/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg',
);
assert.match(gibsTemplate('L30', '2026-09-10'), /HLS_L30_.*Level12.*\.png$/);
assert.throws(() => gibsTemplate('MODIS', '2026-01-01'), /Unknown/);
assert.equal(
wvsSnapshotUrl({
product: 'S30',
day: '2026-09-18',
box: BOX,
width: 256,
height: 256,
}),
'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&CRS=EPSG:4326&TIME=2026-09-18&BBOX=30.2,-97.8,30.3,-97.7&WIDTH=256&HEIGHT=256&FORMAT=image/png',
);
assert.throws(
() => wvsSnapshotUrl({ product: 'S30', day: 'x', box: null }),
TypeError,
);
});
test('thumbnails load the focused card, then the visible cards outward, then the margins', () => {
assert.deepEqual(thumbnailOrder(5, 3, 7, 20), [5, 6, 4, 7, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(19, 17, 19, 20), [19, 18, 17, 16, 15]);
assert.deepEqual(thumbnailOrder(50, 7, 3, 10), [7, 6, 5, 4, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(0, 0, 0, 0), []);
});
+265
View File
@@ -0,0 +1,265 @@
/**
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
* host the map stack offers. Against the basemap slot `a` splits left and
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
* host switch or a replaced image never strands one. Tile requests pass a
* small semaphore of our own; Cesium's global request limits stay untouched.
*/
import * as Cesium from 'cesium';
import { PRODUCTS, gibsTemplate } from './model.js';
const SLOT_IDS = ['a', 'b'];
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
/** Render reason for a deferred or settled tile request. */
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
/** Delay before the one frame that retries tiles Cesium itself deferred. */
export const TILE_RETRY_DELAY_MS = 250;
function clampAlpha(value) {
const alpha = Number(value);
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
}
const boxKey = (box) =>
[box.west, box.south, box.east, box.north].map(Number).join(',');
/**
* Gate a provider's `requestImage` through the semaphore. Returning
* `undefined` tells Cesium to retry on a later frame, but under
* `requestRenderMode` there is no later frame unless something asks — and a
* deferral must never ask for one itself: the retried tile would defer again
* and ask again, keeping the scene rendering every frame. So a request that
* finds our semaphore full asks for nothing (the request holding the slot
* asks for one frame when it settles), and a request the provider itself
* deferred goes through the one coalesced delayed retry.
*/
function admitTileRequests(provider, admission, requestRender, retry) {
const original = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (admission.inFlight >= admission.limit) return undefined;
admission.inFlight += 1;
const release = () => {
admission.inFlight -= 1;
};
let promise;
try {
promise = original(x, y, level, request);
} catch (error) {
release();
throw error;
}
if (!promise) {
release();
retry.schedule();
return undefined;
}
return Promise.resolve(promise).finally(() => {
release();
requestRender(TILE_RENDER_REASON);
});
};
return provider;
}
/**
* Create the renderer.
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
*/
export function createRecentImageryRenderer({
cesium = Cesium,
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
} = {}) {
let host = NO_HOST;
let destroyed = false;
const admission = { limit: maxTileRequests, inFlight: 0 };
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
const owned = { a: null, b: null };
// One timer for both slots: any number of upstream deferrals while it is
// armed share the same single retry frame.
let retryTimer = null;
const retry = {
schedule() {
if (retryTimer !== null) return;
retryTimer = setTimeoutImpl(() => {
retryTimer = null;
if (!destroyed) requestRender(TILE_RENDER_REASON);
}, TILE_RETRY_DELAY_MS);
},
cancel() {
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
retryTimer = null;
},
};
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
tilingScheme: new cesium.WebMercatorTilingScheme(),
rectangle: cesium.Rectangle.fromDegrees(
box.west,
box.south,
box.east,
box.north,
),
credit: 'NASA GIBS',
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
}
function removeOwned(slotId) {
const record = owned[slotId];
if (!record) return false;
owned[slotId] = null;
try {
record.collection.remove(record.layer, true);
} catch {
/* the collection is already gone */
}
return true;
}
function applyLook(record) {
record.layer.alpha = record.alpha;
record.layer.splitDirection =
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
}
function mount(slotId, { candidate, box, alpha, split }) {
const layer = host.collection.addImageryProvider(
createProvider(candidate, box),
);
owned[slotId] = {
layer,
collection: host.collection,
candidate,
box,
alpha,
split,
};
applyLook(owned[slotId]);
}
return {
/**
* Drape a candidate into a slot. The same candidate on the same host only
* updates its look; anything else replaces the slot's layer.
* @param {'a'|'b'} slotId
* @param {object} candidate
* @param {object} box Degrees box.
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
* @returns {boolean} Whether a layer is now draped for the slot.
*/
showSlot(
slotId,
candidate,
box,
{ alpha = 1, splitDirection = 'none' } = {},
) {
if (destroyed || !candidate?.key || !box) return false;
if (!host.collection) {
removeOwned(slotId);
return false;
}
const current = owned[slotId];
const look = { alpha: clampAlpha(alpha), split: splitDirection };
if (
current &&
current.collection === host.collection &&
current.candidate.key === candidate.key &&
boxKey(current.box) === boxKey(box)
) {
if (current.alpha !== look.alpha || current.split !== look.split) {
Object.assign(current, look);
applyLook(current);
requestRender('recent-imagery-look');
}
return true;
}
removeOwned(slotId);
mount(slotId, { candidate, box, ...look });
requestRender('recent-imagery-show');
return true;
},
/** @param {'a'|'b'} slotId */
hideSlot(slotId) {
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
},
/**
* @param {'a'|'b'} slotId
* @param {number} alpha 0–1
*/
setAlpha(slotId, alpha) {
const record = owned[slotId];
const next = clampAlpha(alpha);
if (!record || record.alpha === next) return;
record.alpha = next;
applyLook(record);
requestRender('recent-imagery-alpha');
},
/**
* Move every owned layer to a new host. Layers are rebuilt there, never
* re-parented, so the old collection is left as it was found.
* @param {{ collection: object | null, kind: string }} nextHost
*/
rebind(nextHost) {
host = nextHost || NO_HOST;
let changed = false;
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
if (!record || record.collection === host.collection) continue;
removeOwned(slotId);
changed = true;
if (host.collection && !destroyed) mount(slotId, record);
}
if (changed) requestRender('recent-imagery-rebind');
},
/** What is draped right now, per slot. */
getOwned() {
const out = {};
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
out[slotId] = record
? {
key: record.candidate.key,
kind: host.kind,
alpha: record.alpha,
split: record.split,
}
: null;
}
return out;
},
/** Number of layers this renderer owns (never above two). */
ownedCount() {
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
},
/** Remove everything and refuse further work. */
destroy() {
if (destroyed) return;
destroyed = true;
retry.cancel();
let changed = false;
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
host = NO_HOST;
if (changed) requestRender('recent-imagery-destroy');
},
};
}
+210
View File
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TILE_RENDER_REASON,
TILE_RETRY_DELAY_MS,
createRecentImageryRenderer,
} from './rendering.js';
import { BOX, manualTimers } from './testDoubles.mjs';
/** A provider whose tile requests stay pending until the test settles them. */
class UrlTemplateImageryProvider {
constructor(options) {
this.options = options;
this.calls = [];
this.pending = [];
}
requestImage(x, y, level) {
this.calls.push([x, y, level]);
return new Promise((resolve) => this.pending.push(resolve));
}
}
function fakeCesium(requestImage) {
class Provider extends UrlTemplateImageryProvider {}
if (requestImage) Provider.prototype.requestImage = requestImage;
return {
UrlTemplateImageryProvider: Provider,
WebMercatorTilingScheme: class {},
Rectangle: {
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
},
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
};
}
function fakeCollection() {
const layers = [];
return {
layers,
addImageryProvider(provider) {
const layer = { provider, alpha: 1, splitDirection: 0 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
layer.destroyed = destroy;
},
};
}
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
const globe = fakeCollection();
renderer.rebind({ collection: globe, kind: 'globe' });
const tileFrames = () =>
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
return { renderer, globe, renders, timers, tileFrames };
}
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
const { renderer, globe, renders } = fixture();
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
const [layer] = globe.layers;
assert.match(
layer.provider.options.url,
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
);
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
assert.equal(layer.provider.options.credit, 'NASA GIBS');
assert.deepEqual(layer.provider.options.rectangle, BOX);
assert.equal(layer.alpha, 0.8);
assert.deepEqual(renders, ['recent-imagery-show']);
assert.deepEqual(renderer.getOwned().a, {
key: 'S30:2026-09-18',
kind: 'globe',
alpha: 0.8,
split: 'none',
});
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
assert.equal(layer.splitDirection, -1);
// A different day replaces the layer in its own collection.
renderer.showSlot('a', L30, BOX);
assert.equal(layer.destroyed, true);
assert.equal(globe.layers.length, 1);
// VIIRS is a level-9 JPEG overview with no alpha channel.
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
assert.equal(globe.layers[1].splitDirection, 1);
renderer.setAlpha('b', 7);
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
});
test('rapid replacement never owns more than two layers', () => {
const { renderer, globe } = fixture();
const days = [S30, L30, VIIRS];
for (let i = 0; i < 10; i += 1) {
renderer.showSlot('a', days[i % 3], BOX);
renderer.showSlot('b', days[(i + 1) % 3], BOX);
if (i % 4 === 0) renderer.hideSlot('a');
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
}
renderer.hideSlot('a');
renderer.hideSlot('b');
assert.equal(globe.layers.length, 0);
});
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
const { renderer, globe } = fixture();
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
const tileset = fakeCollection();
renderer.rebind({ collection: tileset, kind: 'tileset' });
assert.equal(globe.layers.length, 0);
assert.deepEqual(
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
[
[0.6, -1],
[1, 1],
],
);
assert.equal(renderer.getOwned().a.kind, 'tileset');
renderer.rebind({ collection: null, kind: 'none' });
assert.equal(tileset.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false);
renderer.rebind({ collection: globe, kind: 'globe' });
renderer.showSlot('a', S30, BOX);
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
});
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
});
renderer.showSlot('a', S30, BOX);
const { provider } = globe.layers[0];
const first = provider.requestImage(0, 0, 1);
assert.ok(first instanceof Promise);
for (let i = 1; i <= 120; i += 1)
assert.equal(provider.requestImage(i, 0, 1), undefined);
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
assert.equal(timers.armed(), 0);
provider.pending[0]('tile');
await first;
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
});
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
let throwNext = false;
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
requestImage() {
if (throwNext) throw new Error('boom');
return undefined;
},
});
renderer.showSlot('a', S30, BOX);
renderer.showSlot('b', L30, BOX);
for (const { provider } of globe.layers)
for (let i = 0; i < 40; i += 1)
assert.equal(provider.requestImage(i, 0, 0), undefined);
// Every deferral released its slot (the limit is one), and none asked
// for a frame of its own: one timer holds the single retry.
assert.equal(tileFrames(), 0);
assert.equal(timers.armed(), 1);
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
timers.flush();
assert.equal(tileFrames(), 1);
// A throwing provider releases its slot too.
throwNext = true;
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
throwNext = false;
globe.layers[0].provider.requestImage(0, 0, 0);
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
renderer.destroy();
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
assert.equal(tileFrames(), 1);
});
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
const { renderer, globe, renders } = fixture();
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
renderer.hideSlot('b');
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
assert.equal(globe.layers[0].splitDirection, -1);
assert.equal(renderer.getOwned().b, null);
const [layer] = globe.layers;
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
+368
View File
@@ -0,0 +1,368 @@
/**
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
* responses, and the Cesium/viewer/document fakes the box tool needs.
*/
import { thumbnailOrder } from './model.js';
export const BOX = Object.freeze({
west: -97.8,
south: 30.2,
east: -97.7,
north: 30.3,
});
export const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Timers that fire only when the test says so. */
export function manualTimers() {
const pending = new Map();
const cleared = [];
let nextId = 1;
return {
pending,
cleared,
setTimeoutImpl(fn, ms) {
const id = nextId++;
pending.set(id, { fn, ms });
return id;
},
clearTimeoutImpl(id) {
cleared.push(id);
pending.delete(id);
},
/** Fire every armed timer, in order. */
flush() {
const entries = [...pending.values()];
pending.clear();
for (const entry of entries) entry.fn();
},
armed: () => pending.size,
};
}
/** A `fetch` response with case-insensitive headers. */
export function response({
ok = true,
status = ok ? 200 : 500,
json = {},
headers = {},
blob = { size: 1 },
} = {}) {
const lower = new Map(
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
);
return {
ok,
status,
headers: { get: (name) => lower.get(name.toLowerCase()) ?? null },
json: async () => json,
blob: async () => blob,
};
}
const EVENT_TYPES = {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
LEFT_DOUBLE_CLICK: 'dblclick',
};
/**
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
*/
export function boxToolFakes() {
const handlers = [];
class ScreenSpaceEventHandler {
constructor(canvas) {
this.canvas = canvas;
this.actions = new Map();
this.destroyed = false;
handlers.push(this);
}
setInputAction(fn, type) {
this.actions.set(type, fn);
}
getInputAction(type) {
return this.actions.get(type);
}
removeInputAction(type) {
this.actions.delete(type);
}
destroy() {
this.destroyed = true;
}
}
class CustomDataSource {
constructor(name) {
this.name = name;
const values = [];
this.entities = {
values,
add: (entity) => (values.push(entity), entity),
removeAll: () => values.splice(0),
};
}
}
const color = { withAlpha: (alpha) => ({ alpha }) };
const cesium = {
ScreenSpaceEventHandler,
ScreenSpaceEventType: EVENT_TYPES,
CustomDataSource,
CallbackProperty: class {},
PolylineDashMaterialProperty: class {},
ClassificationType: { BOTH: 'both' },
Color: { fromCssColorString: () => color },
Rectangle: { fromDegrees: () => ({}) },
Cartesian3: { fromDegreesArray: (values) => values },
};
const stock = new ScreenSpaceEventHandler('stock');
const originalClick = () => 'select';
const originalDouble = () => 'track';
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
const sources = [];
const viewer = {
renders: 0,
scene: {
canvas: { clientWidth: 1000, clientHeight: 500 },
screenSpaceCameraController: { enableInputs: true },
requestRender: () => {
viewer.renders += 1;
},
},
screenSpaceEventHandler: stock,
dataSources: {
sources,
add: (source) => (sources.push(source), Promise.resolve(source)),
remove: (source) => sources.splice(sources.indexOf(source), 1),
},
};
const listeners = [];
const classes = new Set();
const documentRef = {
listeners,
classes,
addEventListener: (type, fn) => listeners.push([type, fn]),
removeEventListener: (type, fn) => {
const index = listeners.findIndex((entry) => entry[1] === fn);
if (index >= 0) listeners.splice(index, 1);
},
body: {
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
},
};
const pickWorld = (_viewer, nx, ny) =>
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
/** The live (non-stock) canvas handler, if the tool has one bound. */
const handler = () =>
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
const fire = (type, event) =>
handler()?.actions.get(EVENT_TYPES[type])?.(event);
/** Dispatch a keydown to the tool's document listener. */
const key = (name) => {
const event = {
key: name,
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopImmediatePropagation() {
this.stopped = true;
},
};
listeners.find(([type]) => type === 'keydown')?.[1](event);
return event;
};
return {
cesium,
viewer,
documentRef,
pickWorld,
handler,
fire,
key,
types: EVENT_TYPES,
originalClick,
originalDouble,
};
}
// ---- the layer's injected parts --------------------------------------
export function candidate(product, day, cloud = 5) {
const granules =
product === 'VIIRS'
? []
: [
{
id: `${product}-${day}`,
product,
timeStart: `${day}T17:12:00Z`,
timeEnd: `${day}T17:14:00Z`,
cloud,
},
];
return {
key: `${product}:${day}`,
product,
day,
granules,
cloud: granules.length ? { min: cloud, max: cloud } : null,
timeRange: granules.length
? { start: granules[0].timeStart, end: granules[0].timeEnd }
: null,
availability: granules.length ? 'present' : 'unknown',
coverage: 'full',
};
}
// Strip order, newest first: V21 (unprobed), S18 (clear), L16 (cloudy), V15.
export const CANDIDATES = Object.freeze([
candidate('VIIRS', '2026-09-21'),
candidate('S30', '2026-09-18', 12),
candidate('L30', '2026-09-16', 60),
candidate('VIIRS', '2026-09-15'),
]);
export function fakeRenderer() {
const calls = [];
const owned = { a: null, b: null };
let host = { kind: 'none' };
let peak = 0;
return {
calls,
rebind(next) {
calls.push(['rebind', next.kind]);
host = next;
for (const slot of ['a', 'b'])
if (owned[slot]) owned[slot] = { ...owned[slot], kind: next.kind };
},
showSlot(slot, cand, box, look) {
if (owned[slot]?.key !== cand.key) calls.push(['show', slot, cand.key]);
owned[slot] = { key: cand.key, kind: host.kind, ...look };
peak = Math.max(peak, this.ownedCount());
return true;
},
hideSlot(slot) {
owned[slot] = null;
},
setAlpha(slot, alpha) {
calls.push(['alpha', slot, alpha]);
},
getOwned: () => ({ ...owned }),
ownedCount: () => ['a', 'b'].filter((slot) => owned[slot]).length,
peak: () => peak,
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeThumbnails() {
const calls = [];
const listeners = new Set();
const statuses = new Map();
/** Every key the layer asked to probe, in the order it asked. */
const requested = [];
/** Direct `request` calls: `[key, priority]`. */
const requests = [];
const ask = (key) => {
if (!requested.includes(key)) requested.push(key);
};
return {
calls,
requested,
requests,
requestOrdered(candidates, box, window) {
calls.push(['ordered', candidates.map((c) => c.key), window]);
const { focusIndex, firstVisible, lastVisible, extra } = window;
thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
).forEach((index) => ask(candidates[index].key));
},
request(candidate, box, priority) {
ask(candidate.key);
requests.push([candidate.key, priority]);
},
/** Settle every requested key still unknown, as the loader would. */
probeRequested(status = 'present') {
for (const key of requested)
if (!statuses.has(key)) this.probe(key, status);
},
clear() {
calls.push(['clear']);
},
get: (key) => ({ status: statuses.get(key) || 'unknown' }),
/** Record a probe result and tell the layer, as the loader does. */
probe(key, status) {
statuses.set(key, status);
for (const listener of listeners) listener(key);
},
/** Record a probe result without telling anyone yet. */
setStatus: (key, status) => statuses.set(key, status),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeCatalog() {
const searches = [];
return {
searches,
searchHls(request) {
return new Promise((resolve, reject) => {
request.signal?.addEventListener('abort', () =>
reject(new Error('aborted')),
);
searches.push({ request, resolve, reject });
});
},
resolveLast(candidates = CANDIDATES) {
searches.at(-1).resolve({ candidates, truncated: false, errors: [] });
},
};
}
/** A map controller whose lease settles when the test says so. */
export function fakeController({ refuse = false } = {}) {
const calls = [];
const controller = {
calls,
acquireImageryComparison(options) {
calls.push(['acquire', options.owner, options.switchPolicy]);
if (refuse) throw new Error('held by bhote');
let settle;
const ready = new Promise((resolve) => {
settle = resolve;
});
controller.lease = {
ready,
release: () => {
calls.push(['release']);
return Promise.resolve();
},
settle: (result = { status: 'ready' }) => settle(result),
};
return controller.lease;
},
};
return controller;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
* candidate day. The response's `Data-Present` header is the only proof that
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
* are decoded images: residency is least-recently-USED, where a request or a
* completed decode is a use and a `get` read never is (the panel reads every
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
const EMPTY = Object.freeze({
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
});
/**
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
size = 256,
} = {}) {
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
const entries = new Map();
const listeners = new Set();
const queue = [];
let inFlight = 0;
let touchCounter = 0;
let destroyed = false;
function notify(key) {
for (const listener of [...listeners]) {
try {
listener(key);
} catch (error) {
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
}
}
}
function touch(entry) {
touchCounter += 1;
entry.touched = touchCounter;
}
function revokeImage(entry) {
if (!entry.objectUrl) return;
try {
revokeObjectUrl(entry.objectUrl);
} catch {
/* a revoked URL is already gone */
}
entry.objectUrl = null;
}
function evictDecoded(keep) {
const decoded = [...entries.values()]
.filter((entry) => entry.objectUrl && entry !== keep)
.sort((a, b) => a.touched - b.touched);
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
for (const entry of decoded) {
if (count <= resident) break;
revokeImage(entry);
notify(entry.key);
count -= 1;
}
}
function enqueue(entry) {
if (!queue.includes(entry)) queue.push(entry);
pump();
}
async function run(entry) {
const controller = new AbortController();
entry.controller = controller;
entry.loading = true;
inFlight += 1;
let status = 'error';
let objectUrl = null;
let acquisitionTime = null;
let cancelled = false;
try {
const url = wvsSnapshotUrl({
product: entry.candidate.product,
day: entry.candidate.day,
box: entry.box,
width: size,
height: size,
});
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
const present = String(
response.headers?.get?.('Data-Present') ?? 'true',
).toLowerCase();
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
if (present === 'false') {
status = 'empty';
} else {
const blob = await response.blob();
if (controller.signal.aborted) cancelled = true;
else {
objectUrl = createObjectUrl(blob);
status = 'present';
}
}
}
} catch (error) {
if (controller.signal.aborted || error?.name === 'AbortError')
cancelled = true;
else status = 'error';
} finally {
inFlight -= 1;
entry.loading = false;
entry.controller = null;
}
// Every settlement frees a slot, so a cancelled fetch pumps too.
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
if (objectUrl) revokeObjectUrl(objectUrl);
pump();
return;
}
// A failed re-fetch of an evicted image keeps what the day proved.
if (status !== 'error' || entry.status !== 'present') {
entry.status = status;
entry.acquisitionTime = acquisitionTime;
}
entry.objectUrl = objectUrl;
touch(entry);
if (objectUrl) evictDecoded(entry);
notify(entry.key);
pump();
}
function pump() {
if (destroyed) return;
queue.sort((a, b) => a.priority - b.priority);
while (inFlight < limit && queue.length) {
const entry = queue.shift();
if (entries.get(entry.key) !== entry) continue;
void run(entry);
}
}
return {
/**
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
* already known is only touched (and re-prioritised while it waits); a
* known-present day whose image was evicted is fetched again.
* @param {object} candidate
* @param {object} box Degrees box.
* @param {number} [priority]
*/
request(candidate, box, priority = 0) {
if (destroyed || !candidate?.key || !box) return;
const key = candidate.key;
const existing = entries.get(key);
if (existing) {
touch(existing);
if (existing.loading) return;
if (existing.status === 'unknown') {
existing.priority = priority;
} else if (existing.status === 'present' && !existing.objectUrl) {
existing.priority = priority;
enqueue(existing);
}
return;
}
const entry = {
key,
candidate,
box,
priority,
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
controller: null,
touched: 0,
};
touch(entry);
entries.set(key, entry);
enqueue(entry);
},
/**
* Request the visible window of a strip in `thumbnailOrder` priority.
* @param {Array<object>} candidates
* @param {object} box
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
*/
requestOrdered(
candidates,
box,
{ focusIndex, firstVisible, lastVisible, extra },
) {
const order = thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
);
order.forEach((index, priority) => {
this.request(candidates[index], box, priority);
});
},
/**
* Abort every fetch in flight and forget every unsettled request. A
* known day whose image was being fetched again keeps what it knows.
*/
cancelAll() {
queue.length = 0;
for (const entry of [...entries.values()]) {
if (entry.loading) entry.controller?.abort();
if (entry.status === 'unknown') entries.delete(entry.key);
}
},
/** Cancel everything and revoke every decoded thumbnail. */
clear() {
this.cancelAll();
for (const entry of entries.values()) revokeImage(entry);
entries.clear();
},
/**
* Read a day's state. A read is not a use: it never changes residency.
* @param {string} key Candidate key.
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
*/
get(key) {
const entry = entries.get(key);
if (!entry) return EMPTY;
return {
status: entry.status,
objectUrl: entry.objectUrl,
acquisitionTime: entry.acquisitionTime,
loading: entry.loading,
};
},
/** @param {(key: string) => void} listener */
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
stats() {
return {
inFlight,
queued: queue.length,
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
.length,
tracked: entries.size,
};
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
listeners.clear();
},
};
}
@@ -0,0 +1,317 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createThumbnailLoader } from './thumbnails.js';
import { BOX, response, settle } from './testDoubles.mjs';
const candidate = (product, day) => ({
key: `${product}:${day}`,
product,
day,
});
/** A fetch whose responses the test releases, in any order. */
function fakeFetch() {
const calls = [];
const impl = (url, { signal } = {}) =>
new Promise((resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
calls.push({ url, signal, resolve });
});
const respond = (index, { ok = true, present = 'true' } = {}) =>
calls[index].resolve(
response({
ok,
headers: {
'Data-Present': present,
'Acquisition-Time': '2026-09-18T17:12:00Z',
},
}),
);
return { impl, calls, respond };
}
function fixture(options = {}) {
const fetch = fakeFetch();
const created = [];
const revoked = [];
const loader = createThumbnailLoader({
fetchImpl: fetch.impl,
createObjectUrl: () => {
created.push(`blob:${created.length + 1}`);
return created.at(-1);
},
revokeObjectUrl: (url) => revoked.push(url),
...options,
});
return { loader, fetch, created, revoked };
}
test('Data-Present decides the day: present with an object URL and sensing time, empty, or error', async () => {
const { loader, fetch, created } = fixture();
const changes = [];
loader.subscribe((key) => changes.push(key));
loader.request(candidate('S30', '2026-09-18'), BOX, 0);
loader.request(candidate('L30', '2026-09-16'), BOX, 1);
loader.request(candidate('VIIRS', '2026-09-15'), BOX, 2);
assert.equal(loader.get('S30:2026-09-18').loading, true);
assert.match(
fetch.calls[0].url,
/wvs\.earthdata\.nasa\.gov.*HLS_S30.*TIME=2026-09-18.*WIDTH=256&HEIGHT=256/,
);
fetch.respond(0);
fetch.respond(1, { present: 'false' });
fetch.respond(2, { ok: false });
await settle();
assert.deepEqual(loader.get('S30:2026-09-18'), {
status: 'present',
objectUrl: 'blob:1',
acquisitionTime: '2026-09-18T17:12:00Z',
loading: false,
});
assert.equal(loader.get('L30:2026-09-16').status, 'empty');
assert.equal(loader.get('VIIRS:2026-09-15').status, 'error');
assert.deepEqual(created, ['blob:1']);
assert.deepEqual(changes.sort(), [
'L30:2026-09-16',
'S30:2026-09-18',
'VIIRS:2026-09-15',
]);
assert.equal(loader.get('nope').status, 'unknown');
});
test('at most maxInFlight fetches run and the queue drains by priority', async () => {
const { loader, fetch } = fixture({ maxInFlight: 2 });
loader.request(candidate('S30', '2026-09-10'), BOX, 5);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-13'), BOX, 3);
// The pump is eager: the first two arrivals start at once, the rest queue.
assert.equal(fetch.calls.length, 2);
assert.match(fetch.calls[0].url, /TIME=2026-09-10/);
assert.match(fetch.calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 2,
queued: 2,
decoded: 0,
tracked: 4,
});
fetch.respond(0);
await settle();
assert.equal(fetch.calls.length, 3);
assert.match(fetch.calls[2].url, /TIME=2026-09-12/, 'priority 0 before 3');
// A repeat request for a queued key just re-prioritises it.
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(loader.stats().tracked, 4);
fetch.respond(1);
await settle();
assert.equal(fetch.calls.length, 4);
assert.match(fetch.calls[3].url, /TIME=2026-09-13/);
});
test('requestOrdered loads the focused card first, then outward, then the margins', () => {
const { loader, fetch } = fixture({ maxInFlight: 10 });
const candidates = Array.from({ length: 8 }, (_, i) =>
candidate('S30', `2026-09-${String(10 + i).padStart(2, '0')}`),
);
loader.requestOrdered(candidates, BOX, {
focusIndex: 3,
firstVisible: 2,
lastVisible: 5,
extra: 1,
});
const days = fetch.calls.map(
(call) => /TIME=2026-09-(\d\d)/.exec(call.url)[1],
);
assert.deepEqual(days, ['13', '14', '12', '15', '16', '11']);
});
test('decoded thumbnails are evicted least-recently-used and revoked; a read is not a use and an evicted day keeps what it learned', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 10,
maxDecoded: 2,
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 2);
fetch.respond(0);
fetch.respond(1);
await settle();
assert.equal(loader.stats().decoded, 2);
loader.get('S30:2026-09-10'); // reading the oldest does not make it recent
fetch.respond(2);
await settle();
assert.equal(loader.stats().decoded, 2);
assert.deepEqual(revoked, ['blob:1']);
const evicted = loader.get('S30:2026-09-10');
assert.equal(evicted.status, 'present', 'availability survives eviction');
assert.equal(evicted.objectUrl, null);
assert.equal(evicted.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(loader.stats().tracked, 3, 'the entry is kept');
assert.equal(loader.get('S30:2026-09-11').objectUrl, 'blob:2');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A repeat request IS a use, and re-fetches an evicted day without ever
// reporting it unknown; 11 is now the LRU and goes when 10 comes back.
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4, 'an evicted day is fetched again');
assert.equal(loader.get('S30:2026-09-10').status, 'present');
assert.equal(loader.get('S30:2026-09-10').loading, true);
fetch.respond(3);
await settle();
assert.equal(loader.get('S30:2026-09-10').objectUrl, 'blob:4');
assert.deepEqual(revoked, ['blob:1', 'blob:2']);
assert.equal(loader.get('S30:2026-09-11').status, 'present');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A day that already holds an image is not fetched twice.
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4);
});
test('reading every card in strip order (a snapshot) never decides who is evicted; evicted days stay known and come back on request', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 20,
maxDecoded: 9,
});
const candidates = Array.from({ length: 20 }, (_, i) =>
candidate('S30', `2026-09-${String(i + 1).padStart(2, '0')}`),
);
candidates.forEach((c, i) => loader.request(c, BOX, i));
assert.equal(fetch.calls.length, 20);
// Decode the strip back to front, leaving the newest card in flight.
for (let i = 19; i >= 1; i -= 1) fetch.respond(i);
await settle();
const holders = () =>
candidates.filter((c) => loader.get(c.key).objectUrl).map((c) => c.key);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
'the nine most recently decoded',
);
// Sweep every card in strip order, several times, as getSnapshot does.
for (let pass = 0; pass < 3; pass += 1)
for (const c of candidates) loader.get(c.key);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
);
// The last decode evicts the least recently DECODED card, not the first
// card the sweep happened to read.
fetch.respond(0);
await settle();
assert.deepEqual(
holders(),
candidates.slice(0, 9).map((c) => c.key),
);
assert.equal(revoked.length, 11);
for (const c of candidates) {
const entry = loader.get(c.key);
assert.equal(entry.status, 'present', `${c.key} stays present`);
assert.equal(entry.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(entry.loading, false);
}
// Re-requesting an evicted day fetches it again and it holds an image.
const evictedKey = candidates[15].key;
assert.equal(loader.get(evictedKey).objectUrl, null);
loader.request(candidates[15], BOX, 0);
assert.equal(fetch.calls.length, 21);
assert.equal(loader.get(evictedKey).status, 'present');
fetch.respond(20);
await settle();
assert.ok(loader.get(evictedKey).objectUrl);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
});
test('cancelAll aborts in-flight fetches and a late settle never leaks an object URL', async () => {
const { loader, fetch, created, revoked } = fixture({ maxInFlight: 1 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
const signal = fetch.calls[0].signal;
loader.cancelAll();
assert.equal(signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 0,
});
await settle();
assert.equal(loader.stats().inFlight, 0);
assert.deepEqual(created, []);
assert.deepEqual(revoked, []);
assert.equal(loader.get('S30:2026-09-10').status, 'unknown');
});
test('clear and destroy revoke every resident thumbnail', async () => {
const { loader, fetch, revoked } = fixture({ maxInFlight: 10 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
fetch.respond(0);
fetch.respond(1);
await settle();
loader.clear();
assert.deepEqual(revoked.sort(), ['blob:1', 'blob:2']);
assert.equal(loader.stats().tracked, 0);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
fetch.respond(2);
await settle();
loader.destroy();
assert.equal(revoked.length, 3);
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(fetch.calls.length, 3, 'a destroyed loader fetches nothing');
});
test('a request queued behind an aborted fetch still starts once the abort settles', async () => {
// A response that arrives after the abort (the fetch ignored the signal).
const calls = [];
const fetchImpl = (url, { signal } = {}) =>
new Promise((resolve) => calls.push({ url, signal, resolve }));
const loader = createThumbnailLoader({
fetchImpl,
maxInFlight: 1,
createObjectUrl: () => 'blob:x',
revokeObjectUrl: () => {},
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.clear();
loader.request(candidate('S30', '2026-09-11'), BOX, 0);
assert.equal(calls[0].signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 1,
decoded: 0,
tracked: 1,
});
calls[0].resolve({
ok: true,
headers: { get: () => null },
blob: async () => ({}),
});
await settle();
assert.equal(calls.length, 2, 'the replacement request was pumped');
assert.match(calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
// The same sequence when the fetch rejects with AbortError instead.
const rejecting = fixture({ maxInFlight: 1 });
rejecting.loader.request(candidate('S30', '2026-09-10'), BOX, 0);
rejecting.loader.clear();
rejecting.loader.request(candidate('S30', '2026-09-11'), BOX, 0);
await settle();
assert.equal(rejecting.fetch.calls.length, 2);
assert.deepEqual(rejecting.loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
});
+5
View File
@@ -8,6 +8,7 @@ import { createTesting } from './testing.js';
import { createInteraction } from './interaction.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createRecords } from './records.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
@@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) {
parts.catalog = createCatalog(context);
parts.labels = createLabels(context);
parts.orbits = createOrbits(context);
parts.records = createRecords(context);
parts.rendering = createRendering(context);
parts.tracking = createTracking(context);
parts.testing = createTesting(context);
@@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) {
applySatellitePointFocusDeemphasis:
parts.rendering.applySatellitePointFocusDeemphasis,
getNextIssPass: parts.orbits.getNextIssPass,
getNextSatellitePass: parts.orbits.getNextSatellitePass,
resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass,
getAnalystRecords: parts.records.getAnalystRecords,
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
+84 -6
View File
@@ -7,7 +7,7 @@ import {
degreesLat,
twoline2satrec,
} from 'satellite.js';
import { findNextIssPass } from '../../data/issPass.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -136,22 +136,98 @@ export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Next ISS pass for an observer. Requires the catalog to have loaded (the
* satellites layer enabled at least once this session).
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}}
*/
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
const sat = layerState._catalog.get(ISS_NORAD);
function getNextIssPass({
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
}) {
return getNextSatellitePass(ISS_NORAD, {
latDeg,
lonDeg,
minElevDeg,
requireVisible,
fromMs,
});
}
/**
* Next pass of any catalog satellite for an observer.
* @param {number} noradId NORAD satellite catalog ID
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
*/
function getNextSatellitePass(
noradId,
{
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
},
) {
const id = Number(noradId);
if (!Number.isSafeInteger(id) || id <= 0) return { status: 'no-tle' };
const sat = layerState._catalog.get(id);
if (!sat || !sat.satrec) return { status: 'no-tle' };
const pass = findNextIssPass({
const pass = findNextSatellitePass({
satrec: sat.satrec,
latDeg,
lonDeg,
fromMs: Date.now(),
fromMs,
minElevDeg,
requireVisible,
});
return pass ? { status: 'ok', pass } : { status: 'none' };
}
/** Resolve only loaded catalog identities; ambiguous names never pick a first row. */
function resolveSatelliteForPass(query) {
const text = String(query ?? '').trim();
if (!text || text.length > 120) return { status: 'not-found' };
const exactId = /^\d+$/.test(text) ? Number(text) : null;
if (exactId !== null) {
const sat = layerState._catalog.get(exactId);
return sat?.satrec
? { status: 'ok', noradId: exactId, name: sat.name }
: { status: 'not-found' };
}
const q = text.toLowerCase();
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
const exact = rows.filter(
([, sat]) => String(sat.name).toLowerCase() === q,
);
const matches = exact.length
? exact
: rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
if (!matches.length) return { status: 'not-found' };
if (matches.length > 1)
return {
status: 'ambiguous',
candidates: matches
.slice(0, 8)
.map(([noradId, sat]) => ({ noradId, name: sat.name })),
totalMatches: matches.length,
};
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
}
/**
* Score a mission-to-catalog name match. Compact identifier containment handles
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
@@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
orbitalPeriodSeconds,
computeOrbitPath,
getNextIssPass,
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import { ISS_NORAD } from './policy.js';
/**
* Map one CelesTrak catalog satellite to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. `id` is the display name (same
* convention as vessels); `noradId` is the track_entity key.
* @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}.
* @returns {{id: string, noradId: string|null, name: string|null,
* lat: number|null, lon: number|null, altitudeM: number|null,
* speedMps: number|null, satelliteClass: string|null, group: string|null}}
*/
export function mapAnalystRecord(raw) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
const noradNum =
raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
const noradId = Number.isFinite(noradNum)
? String(Math.trunc(noradNum))
: text(raw?.noradId);
const name = text(raw?.name);
const group = text(raw?.group);
const isIss = noradNum === ISS_NORAD;
return {
id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'),
noradId,
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
altitudeM: num(raw?.altitudeM),
speedMps: num(raw?.speedMps),
// Empty records stay class-less; the class table's fallback is VISUAL and
// must not leak onto a row that has no satellite identity.
satelliteClass:
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
group,
};
}
/** Create on-demand analyst snapshots owned by one layer instance. */
export function createRecords({ state, parts }) {
function analystGeo(noradId, sat, now) {
const pos = sat?.satrec
? parts.orbits.propagatePosition(sat.satrec, now)
: null;
if (
pos &&
Number.isFinite(pos.latitude) &&
Number.isFinite(pos.longitude)
) {
return {
lat: pos.latitude,
lon: pos.longitude,
altitudeM: pos.altitude,
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
};
}
const point = state._points.get(noradId);
if (!point?.position) return null;
const carto = Cesium.Cartographic.fromCartesian(point.position);
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
speedMps: null,
};
}
function getAnalystRecords(maxCount = 2000) {
if (!state._enabled || !state._catalog.size) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const now = new Date();
const result = [];
const emit = (noradId, sat) => {
if (result.length >= limit) return false;
const pos = analystGeo(noradId, sat, now);
if (!pos) return true;
result.push(
mapAnalystRecord({
noradId,
name: sat?.name,
group: sat?.group,
lat: pos.lat,
lon: pos.lon,
altitudeM: pos.altitudeM,
speedMps: pos.speedMps,
}),
);
return result.length < limit;
};
for (const [noradId, sat] of state._catalog) {
if (sat?.group === 'dense') continue;
if (!emit(noradId, sat)) break;
}
if (result.length < limit) {
for (const [noradId, sat] of state._catalog) {
if (sat?.group !== 'dense') continue;
if (!emit(noradId, sat)) break;
}
}
return result;
}
return { getAnalystRecords };
}
+108
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { createSatelliteSource } from './source.js';
import { createSatellitesLayer } from './index.js';
@@ -56,3 +57,110 @@ test('satellite factories keep control state separate and construct without requ
assert.equal(second.getParams().showPoints, true);
assert.notEqual(first.getStats(), second.getStats());
});
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
const source = { readGroup() {} };
const services = Object.fromEntries(
[
'picking',
'focus',
'readout',
'overlays',
'context',
'render',
'layerState',
].map((key) => [key, {}]),
);
services.layerState.isExplicitLayerStateOrigin = () => false;
const layer = createSatellitesLayer({ source, services });
// Cold catalog (no satellites loaded yet)
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
status: 'no-tle',
});
assert.deepEqual(
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// String NORAD ID coercion
assert.deepEqual(
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Invalid / non-finite ID handling
assert.deepEqual(
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
assert.deepEqual(
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Populated catalog: verify numeric and string coercion succeed
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const satrec = twoline2satrec(L1, L2);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
});
const FROM_MS = 1221868800000;
const numericResult = layer.getNextSatellitePass(25544, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(numericResult.status, 'ok');
assert.ok(numericResult.pass);
const stringResult = layer.getNextSatellitePass('25544', {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(stringResult.status, 'ok');
assert.deepEqual(stringResult, numericResult);
const issResult = layer.getNextIssPass({
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(issResult.status, 'ok');
assert.deepEqual(issResult, numericResult);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
neighbours: [
{ noradId: 10, name: 'STARLINK ONE', satrec },
{ noradId: 11, name: 'STARLINK TWO', satrec },
],
});
assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous');
assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2);
assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10);
assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found');
layer._clearDenseCatalogStateForTest();
assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found');
// Non-existent satellite ID in populated catalog
assert.deepEqual(
layer.getNextSatellitePass(99999, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
}),
{ status: 'no-tle' },
);
});
+23
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { createTrafficSource } from './source.js';
import { clampBoundsAroundCenter } from '../../data/trafficBounds.js';
const bounds = { south: 30.267, west: -97.744, north: 30.268, east: -97.743 };
const fixture = readFileSync(
new URL(
@@ -87,6 +88,28 @@ test('road requests have finite bounds and retain the two-pass query', async ()
/residential/,
);
});
test('antimeridian clamps produce road bounds accepted on either side', async () => {
const calls = [];
const source = createTrafficSource({
fetchImpl: async (...args) => {
calls.push(args);
return new Response('{"elements":[]}');
},
});
for (const centerLon of [179.99, -179.99]) {
const clamped = clampBoundsAroundCenter(
{
south: -0.02,
north: 0.02,
west: 179.98,
east: -179.98,
},
{ lat: 0, lon: centerLon },
);
await source.requestRoads(clamped);
}
assert.equal(calls.length, 2);
});
test('malformed availability is an unavailable source rather than a keyless response', async () => {
const source = createTrafficSource({
fetchImpl: async () => new Response('{}'),
+13 -2
View File
@@ -54,7 +54,14 @@ export function createSelection({
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
const siblingOwned =
pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
// Cyclone geometry is ambient context behind painted cards. Its handler
// yields to the same foreground AIS hit. Keep moving-contact precedence
// unchanged: those handlers may issue their own camera command.
const cycloneBackground =
siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
if (siblingOwned && !cycloneBackground) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
// usually terrain behind the card. Resolve against the host's current
@@ -64,10 +71,14 @@ export function createSelection({
click.position?.x,
click.position?.y,
{
sourceId: VESSEL_OVERLAY_SOURCE_ID,
...(cycloneBackground
? {}
: { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
},
)
: null;
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
return;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
+218
View File
@@ -0,0 +1,218 @@
export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** Transient observed time. Products must honor the signal before installing a frame. */
export function createWeatherClock({
now = Date.now,
setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout,
} = {}) {
const registry = new Map();
const listeners = new Set();
let mode = 'latest';
let target = null;
let playing = false;
let generation = 0;
let timer = null;
let pending = null;
let destroyed = false;
const timesFor = (product) =>
[...new Set(product.getTimes())]
.filter((time) => Number.isFinite(Date.parse(time)))
.sort((a, b) => Date.parse(a) - Date.parse(b));
const getTimeline = () =>
[
...new Set(
[...registry.values()].flatMap(({ product }) =>
product.isSuspended() ? [] : timesFor(product),
),
),
].sort((a, b) => Date.parse(a) - Date.parse(b));
function selectFor(id, time) {
const product = registry.get(id)?.product;
if (!product) return null;
const at = Date.parse(time);
return (
timesFor(product).findLast((candidate) => {
const age = at - Date.parse(candidate);
return age >= 0 && age <= product.maxGapMs;
}) ?? null
);
}
const selectedFor = (product) =>
mode === 'latest'
? (timesFor(product).at(-1) ?? null)
: selectFor(product.id, target);
const getState = () => ({
mode,
target,
playing,
timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
shown: product.getShownTime() ?? null,
selected: selectedFor(product),
suspended: Boolean(product.isSuspended()),
})),
});
const notify = () => {
if (!destroyed) for (const listener of listeners) listener(getState());
};
const cancelTimer = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
playing = false;
notify();
return;
}
const owner = generation;
timer = setTimeout(() => {
timer = null;
if (destroyed || !playing || owner !== generation) return;
const timeline = getTimeline();
if (timeline.length < 2) {
api.pause();
return;
}
const next = timeline.find(
(time) => Date.parse(time) > Date.parse(target),
);
void api.setTarget(next ?? timeline[0]);
}, 2000);
}
function transition() {
if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation;
if (!canPlay()) playing = false;
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
entry.controller = new AbortController();
const { signal } = entry.controller;
const time = selectedFor(entry.product);
return Promise.resolve().then(() => {
if (signal.aborted || owner !== generation || destroyed) return false;
return entry.product.apply(time, { signal });
});
});
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
pending = null;
if (!canPlay()) playing = false;
notify();
schedule();
return true;
});
pending = result;
notify();
return result;
}
const api = {
register(product) {
if (destroyed) return () => {};
if (registry.has(product.id))
throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null };
registry.set(product.id, entry);
void api.refresh();
return () => {
if (registry.get(product.id) !== entry) return;
entry.controller?.abort();
registry.delete(product.id);
void api.refresh();
};
},
getTimeline,
selectFor,
setTarget(time) {
if (destroyed) return Promise.resolve(false);
if (!Number.isFinite(Date.parse(time)))
throw new TypeError('Weather target must be a UTC time');
const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
mode = 'history';
target = normalized;
return transition();
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
return Promise.resolve(false);
api.pause();
const timeline = getTimeline();
if (!timeline.length) return Promise.resolve(false);
const at = Date.parse(target ?? timeline.at(-1));
const next =
direction < 0
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
return api.setTarget(next);
},
latest() {
if (destroyed) return Promise.resolve(false);
mode = 'latest';
target = null;
playing = false;
return transition();
},
play() {
if (destroyed || playing || !canPlay())
return pending ?? Promise.resolve(false);
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
return api.setTarget(
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
);
}
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
if (!playing) return;
playing = false;
notify();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
refresh() {
if (destroyed) return Promise.resolve(false);
if (!canPlay()) {
playing = false;
cancelTimer();
}
if (mode === 'history') return transition();
notify();
return Promise.resolve(true);
},
getState,
subscribe(listener) {
if (!destroyed) listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
++generation;
playing = false;
cancelTimer();
for (const entry of registry.values()) entry.controller?.abort();
registry.clear();
listeners.clear();
pending = null;
},
};
return api;
}
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherClock,
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
function product(id, minutes, maxGapMs = RADAR_MAX_GAP_MS) {
const p = {
id,
maxGapMs,
times: minutes.map(time),
shown: null,
suspended: false,
calls: [],
getTimes: () => p.times,
getShownTime: () => p.shown,
isSuspended: () => p.suspended,
async apply(selected, { signal }) {
p.calls.push({ selected, signal });
if (signal.aborted) return false;
p.shown = selected;
return true;
},
};
return p;
}
function harness(t) {
let at = Date.parse(time(20));
const timers = new Map();
let id = 0;
const clock = createWeatherClock({
now: () => at,
setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
});
t.after(() => clock.destroy());
return {
clock,
timers,
tick(ms) {
at += ms;
for (const [id, timer] of [...timers]) {
if (timer.due > at) continue;
timers.delete(id);
timer.fn();
}
},
};
}
test('union excludes suspended products; nearest selection never uses future or out-of-gap frames', (t) => {
const { clock } = harness(t);
const radar = product('radar', [10, 0, 10]);
const regional = product('regional', [5, 10]);
const global = product('global', [-180, -60], GLOBAL_INFRARED_MAX_GAP_MS);
const hidden = product('hidden', [99]);
hidden.suspended = true;
for (const p of [radar, regional, global, hidden]) clock.register(p);
assert.deepEqual(clock.getTimeline(), [-180, -60, 0, 5, 10].map(time));
assert.equal(clock.selectFor('radar', time(9)), time(0));
assert.equal(clock.selectFor('radar', time(-1)), null);
assert.equal(clock.selectFor('radar', time(40)), time(10));
assert.equal(clock.selectFor('radar', time(41)), null);
assert.equal(clock.selectFor('global', time(120)), time(-60));
assert.equal(clock.selectFor('global', time(121)), null);
assert.equal(clock.selectFor('missing', time(10)), null);
assert.equal(REGIONAL_INFRARED_MAX_GAP_MS, 30 * 60_000);
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
});
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]);
const satellite = product('satellite', [5, 15]);
const lightning = product('lightning', [15]);
for (const p of [radar, satellite, lightning]) clock.register(p);
await clock.setTarget(time(10));
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(10), time(5), null],
);
assert.deepEqual(
clock.getState().products.map(({ selected }) => selected),
[time(10), time(5), null],
);
await clock.latest();
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(20), time(15), time(15)],
);
assert.equal(clock.getState().mode, 'latest');
assert.equal(clock.getState().target, null);
assert.equal(clock.getState().playing, false);
});
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
const { clock, timers } = harness(t);
const p = product('radar', [0, 10, 20]);
const loads = [];
p.apply = (selected, { signal }) =>
new Promise((resolve) => {
loads.push({
selected,
signal,
finish() {
if (!signal.aborted) p.shown = selected;
resolve(!signal.aborted);
},
});
});
clock.register(p);
let notifications = 0;
clock.subscribe(() => notifications++);
const old = clock.setTarget(time(0));
await flush();
const current = clock.setTarget(time(10));
await flush();
assert.equal(loads[0].signal.aborted, true);
loads[1].finish();
assert.equal(await current, true);
const count = notifications;
loads[0].finish();
assert.equal(await old, false);
assert.equal(notifications, count);
assert.equal(p.shown, time(10));
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
});
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
const { clock } = harness(t);
clock.register(product('radar', [0, 10, 20]));
clock.register(product('satellite', [5, 15]));
await clock.step(-1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(12));
await clock.step(-1);
assert.equal(clock.getState().target, time(10));
await clock.setTarget(time(12));
await clock.step(1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(0));
await clock.step(-1);
assert.equal(clock.getState().target, time(0));
await clock.setTarget(time(20));
await clock.step(1);
assert.equal(clock.getState().target, time(20));
});
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
const { clock, tick, timers } = harness(t);
const a = product('radar', [0, 10]);
const b = product('satellite', [5]);
const loads = [];
b.apply = () =>
new Promise((resolve, reject) => loads.push({ resolve, reject }));
clock.register(a);
clock.register(b);
const start = clock.play();
await flush();
assert.equal(clock.getState().target, time(10));
tick(10_000);
assert.equal(timers.size, 0);
assert.equal(a.calls.length, 1);
loads[0].reject(new Error('unavailable'));
await start;
assert.equal(timers.size, 1);
tick(1999);
assert.equal(clock.getState().target, time(10));
tick(1);
await flush();
assert.equal(clock.getState().target, time(0));
assert.equal(timers.size, 0);
tick(10_000);
assert.equal(clock.getState().target, time(0));
loads[1].resolve(true);
await flush();
tick(2000);
await flush();
assert.equal(clock.getState().target, time(5));
clock.pause();
loads[2].resolve(false);
await flush();
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
});
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
const { clock, tick, timers } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.play();
await clock.play();
assert.equal(timers.size, 1);
clock.pause();
tick(2000);
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
await clock.togglePlay();
p.times = [time(0)];
await clock.refresh();
assert.equal(clock.getState().playing, false);
await clock.play();
assert.equal(clock.getState().playing, false);
p.times = [time(0), time(10)];
await clock.refresh();
await clock.play();
p.suspended = true;
await clock.refresh();
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(timers.size, 0);
});
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
const { clock } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.refresh();
assert.equal(p.calls.length, 0);
await clock.setTarget(time(10));
p.times = [time(0), time(20)];
await clock.refresh();
assert.equal(p.shown, time(0));
p.times = [time(20)];
await clock.refresh();
assert.equal(p.shown, null);
assert.equal(clock.getState().target, time(10));
});
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
const { clock, timers, tick } = harness(t);
const p = product('radar', [0, 10]);
const unregister = clock.register(p);
await clock.setTarget(time(0));
const b = product('satellite', [0, 5]);
clock.register(b);
await flush();
assert.equal(b.shown, time(0));
await clock.play();
unregister();
unregister();
await flush();
assert.equal(p.calls.at(-1).signal.aborted, true);
assert.deepEqual(
clock.getState().products.map(({ id }) => id),
['satellite'],
);
let notifications = 0;
const off = clock.subscribe(() => notifications++);
clock.pause();
off();
const before = notifications;
await clock.play();
assert.equal(notifications, before);
clock.destroy();
clock.destroy();
tick(2000);
assert.equal(timers.size, 0);
assert.equal(b.calls.at(-1).signal.aborted, true);
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false);
});
+15
View File
@@ -0,0 +1,15 @@
/** Resolve the visible surface that can own weather imagery without scene mutation. */
export function resolveImageryHost({ viewer, tileset }) {
if (viewer?.scene?.globe?.show === true)
return { collection: viewer.imageryLayers, kind: 'globe' };
if (tileset?.imageryLayers)
return { collection: tileset.imageryLayers, kind: 'tileset' };
return { collection: null, kind: 'none' };
}
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
/** Report imagery suspension without changing or detaching the resolved host. */
export function imageryHostStatus(host) {
return host.kind === 'none' ? NO_IMAGERY_HOST : null;
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveImageryHost,
imageryHostStatus,
NO_IMAGERY_HOST,
} from './imageryHost.js';
test('only a missing host suspends weather; 3D Tiles has no height gate', () => {
assert.equal(imageryHostStatus({ kind: 'tileset' }), null);
assert.equal(imageryHostStatus({ kind: 'globe' }), null);
assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST);
});
test('visible globe takes precedence over a tileset', () => {
const viewer = { imageryLayers: {}, scene: { globe: { show: true } } };
assert.deepEqual(
resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }),
{ collection: viewer.imageryLayers, kind: 'globe' },
);
});
test('hidden globe uses the supplied tileset imagery collection', () => {
const tileset = { imageryLayers: {} };
const viewer = { scene: { globe: { show: false } } };
assert.deepEqual(resolveImageryHost({ viewer, tileset }), {
collection: tileset.imageryLayers,
kind: 'tileset',
});
});
test('hidden globe without an imagery-capable tileset has no host', () => {
for (const tileset of [null, {}])
assert.deepEqual(
resolveImageryHost({
viewer: { scene: { globe: { show: false } } },
tileset,
}),
{ collection: null, kind: 'none' },
);
});
+17
View File
@@ -0,0 +1,17 @@
const priorities = new WeakMap();
/** Scalar context, infrared, radar, then lightning: independent of enable order. */
export function orderWeatherImagery(collection, layer, priority) {
priorities.set(layer, priority);
if (typeof collection.raiseToTop !== 'function') return;
const weather = [];
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (priorities.has(item)) weather.push(item);
}
const ordered = [...weather].sort(
(a, b) => priorities.get(a) - priorities.get(b),
);
if (weather.every((item, i) => item === ordered[i])) return;
for (const item of ordered) collection.raiseToTop(item);
}
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orderWeatherImagery } from './imageryOrder.js';
test('weather compositing remains consistent when scalar, observation and history arrive out of order', () => {
const base = { name: 'basemap' },
radar = { name: 'radar' },
cloud = { name: 'cloud' },
wind = { name: 'wind' },
lightning = { name: 'lightning' };
const items = [base];
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const [layer, priority] of [
[radar, 2],
[cloud, 1],
[lightning, 3],
[wind, 0],
]) {
items.push(layer);
orderWeatherImagery(collection, layer, priority);
}
assert.deepEqual(items, [base, wind, cloud, radar, lightning]);
const next = { name: 'new radar history frame' };
items.push(next);
orderWeatherImagery(collection, next, 2);
assert.deepEqual(items, [base, wind, cloud, radar, next, lightning]);
});
test('tileset imagery collection preserves scalar, infrared, radar and lightning order', async () => {
const { ImageryLayerCollection, ImageryLayer } = await import('cesium');
const tileset = { imageryLayers: new ImageryLayerCollection() };
const layers = Array.from({ length: 4 }, () => new ImageryLayer());
for (const priority of [3, 2, 0, 1]) {
tileset.imageryLayers.add(layers[priority]);
orderWeatherImagery(tileset.imageryLayers, layers[priority], priority);
}
assert.deepEqual(
layers.map((_, i) => tileset.imageryLayers.get(i)),
layers,
);
tileset.imageryLayers.destroy();
});
test('already ordered imagery adds and repeated ordering cause no collection churn', () => {
const items = [];
let raises = 0;
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
raises++;
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const priority of [0, 1, 1, 2, 3]) {
const layer = {};
items.push(layer);
orderWeatherImagery(collection, layer, priority);
orderWeatherImagery(collection, layer, priority);
}
assert.equal(raises, 0);
});
+727
View File
@@ -0,0 +1,727 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const STOPS = [
[10, '#00ecec'],
[20, '#0000f6'],
[30, '#00c800'],
[40, '#ffff00'],
[50, '#ff9000'],
[60, '#dc0000'],
[70, '#ff00ff'],
[80, '#05ede0'],
];
const LIGHTNING_STOPS = [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
];
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
/** A per-application observation layer, using the existing layer lifecycle and
* row controls. History is transient: shared links always open latest imagery. */
export function createWeatherLayer({
feed,
clock,
id = 'weather-radar',
cesium = Cesium,
createRendering = createWeatherRendering,
documentRef = globalThis.document,
eventTarget = globalThis.window,
matchMedia = globalThis.matchMedia?.bind(globalThis),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Weather requires a snapshot source');
const radar = id === 'weather-radar';
const lightning = id === 'weather-lightning';
const satellite = !radar && !lightning;
let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional';
let opacity = 'strong';
let infrared = 'filtered';
let viewer = null,
rendering = null,
manifest = null,
request = null,
listener = null;
let enabled = false,
loading = false,
playing = false,
followLatest = true;
let generation = 0,
timer = null,
error = null,
removeCamera = null;
let motion = null;
let runNavigation = null;
let imageryHost = null;
let hostCollection;
let hostHidden = false;
let hostStatus = null;
let unregisterClock = null;
let frameRequest = null;
let noFrame = false;
const maxGap = () =>
radar
? RADAR_MAX_GAP_MS
: lightning
? LIGHTNING_MAX_GAP_MS
: product === 'clouds'
? GLOBAL_INFRARED_MAX_GAP_MS
: REGIONAL_INFRARED_MAX_GAP_MS;
const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () =>
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
notify();
});
const observationDelayed = () =>
manifest?.latest &&
Date.now() - Date.parse(manifest.latest) >
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
const stop = () => {
if (clock) return;
playing = false;
rendering?.cancelPrefetch?.();
clearTimeout(timer);
timer = null;
};
const suspended = () => hostHidden || documentRef?.hidden || motion?.matches;
const onVisibility = () => {
if (suspended()) rendering?.cancelPrefetch?.();
if (clock) void clock.refresh();
else if (suspended()) stop();
notify();
};
function checkHost(resume = true) {
const host = getHost();
const status = imageryHostStatus(host);
const changed = host.collection !== hostCollection || hostStatus !== status;
hostCollection = host.collection;
hostStatus = status;
hostHidden = status !== null;
// Keep playback intent and the displayed time while the host is unavailable.
if (!changed || !rendering) return;
++generation;
rendering.rehome?.();
clearTimeout(timer);
timer = null;
loading = Boolean(request && !request.signal.aborted);
if (clock && enabled) {
if (resume) {
void clock.refresh();
if (!hostHidden && manifest && isLatest()) void show(manifest.latest);
}
notify();
return;
}
if (resume && enabled && !hostHidden && manifest) {
const time =
followLatest || !manifest.times.includes(shownTime())
? manifest.latest
: shownTime();
if (
time !== shownTime() ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
void show(time);
else schedule();
}
notify();
}
const onMapStackChanged = () => checkHost();
function schedule() {
if (clock) return;
clearTimeout(timer);
timer = null;
if (!enabled || !playing || suspended()) return;
timer = setTimeout(() => {
timer = null;
const times = manifest?.times || [];
const next = (times.indexOf(shownTime()) + 1) % times.length;
if (times[next]) void show(times[next]);
else stop();
}, 2000);
}
async function applyClockTime(time, { signal }) {
if (!enabled || signal.aborted) return false;
if (time !== null) return show(time, signal);
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = true;
rendering?.setHidden(true);
loading = Boolean(request && !request.signal.aborted);
notify();
return true;
}
function registerClock() {
if (!clock || unregisterClock || !rendering || !enabled) return;
unregisterClock = clock.register({
id,
get maxGapMs() {
return maxGap();
},
getTimes: () => manifest?.times ?? [],
getShownTime: shownTime,
apply: applyClockTime,
isSuspended: suspended,
});
}
async function warmNext(time) {
if (
!enabled ||
suspended() ||
!(clock ? clock.getState().playing : playing)
)
return;
const times = manifest?.times ?? [];
const next = times[(times.indexOf(time) + 1) % times.length];
if (!next || next === time) return;
try {
await rendering.prefetch?.(manifest, next, { infrared });
} catch {
// Speculative work must not change the displayed frame or playback state.
}
}
async function show(time, signal) {
checkHost(false);
if (!enabled || hostHidden || !manifest?.times?.includes(time))
return false;
signal?.throwIfAborted();
frameRequest?.abort();
const controller = new AbortController();
frameRequest = controller;
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const owner = ++generation;
loading = true;
notify();
try {
const ok = await rendering.setFrame(manifest, time, {
signal: controller.signal,
infrared,
});
if (owner !== generation || !enabled || controller.signal.aborted)
return false;
if (ok) {
noFrame = false;
rendering.setHidden?.(false);
void warmNext(time);
}
error = ok ? null : 'Frame unavailable; previous observation retained';
if (!ok) stop();
return ok;
} catch {
if (owner === generation && !controller.signal.aborted) {
error = 'Weather imagery unavailable';
stop();
}
return false;
} finally {
signal?.removeEventListener('abort', abort);
if (frameRequest === controller) frameRequest = null;
if (owner === generation) {
loading = Boolean(request && !request.signal.aborted);
notify();
if (clock && isLatest()) void clock.refresh();
schedule();
}
}
}
const layer = {
id,
name: radar
? 'Rain radar'
: lightning
? 'Lightning density'
: 'Satellite clouds',
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
source: 'NOAA nowCOAST · OBSERVED',
updateInterval: lightning ? 600_000 : 120_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({
viewer,
cesium,
getHost,
onChange: notify,
});
checkHost(false);
eventTarget?.addEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
motion = matchMedia?.('(prefers-reduced-motion: reduce)');
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
checkHost();
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
enabled = true;
registerClock();
},
disable() {
enabled = false;
unregisterClock?.();
unregisterClock = null;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
rendering?.clear();
manifest = null;
loading = false;
error = null;
followLatest = true;
},
async update(_viewer, { signal } = {}) {
if (!enabled) return false;
checkHost(false);
clearTimeout(timer);
timer = null;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => {
controller.abort(signal.reason);
stop();
};
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const snapshot = await feed.getSnapshot({
product,
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (snapshot.unavailable) {
error = 'Weather source unavailable; previous observation retained';
stop();
return true;
}
manifest = snapshot;
error = null;
if (clock) {
await clock.refresh();
if (
isLatest() &&
request === controller &&
!controller.signal.aborted
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
);
}
const time =
followLatest || !snapshot.times.includes(shownTime())
? snapshot.latest
: shownTime();
if (
shownTime() !== time ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Weather unavailable';
stop();
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = Boolean(rendering?.getDiagnostics().loading);
notify();
schedule();
}
}
},
setParams(params = {}) {
const infraredChanged =
satellite &&
['filtered', 'full'].includes(params.infrared) &&
params.infrared !== infrared;
if (infraredChanged) infrared = params.infrared;
if (['light', 'strong'].includes(params.opacity)) {
opacity = params.opacity;
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
}
if (
satellite &&
['clouds', 'clouds-regional'].includes(params.product) &&
params.product !== product
) {
product = params.product;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
manifest = null;
error = null;
loading = false;
followLatest = true;
rendering?.clear();
if (clock) void clock.refresh();
if (enabled) void layer.update(viewer);
}
if (infraredChanged && enabled && manifest) {
clearTimeout(timer);
timer = null;
if (clock && !isLatest()) void clock.refresh();
else void show(shownTime() || manifest.latest);
}
if (
params.focus === true &&
enabled &&
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
b.west,
b.south,
b.east,
b.north,
),
duration: motion?.matches ? 0 : 1.4,
}),
);
}
if (clock && enabled) {
if (params.play === true) void clock.togglePlay();
if (params.latest === true) void clock.latest();
if ([-1, 1].includes(params.step)) void clock.step(params.step);
}
if (
!clock &&
params.play === true &&
enabled &&
manifest?.times?.length > 1 &&
!suspended()
) {
playing = !playing;
followLatest = false;
if (playing) schedule();
else stop();
}
if (!clock && params.latest === true && manifest) {
stop();
followLatest = true;
void show(manifest.latest);
}
if (!clock && [-1, 1].includes(params.step) && manifest && !loading) {
stop();
followLatest = false;
const at = manifest.times.indexOf(shownTime());
const next = Math.max(
0,
Math.min(manifest.times.length - 1, at + params.step),
);
void show(manifest.times[next]);
}
notify();
},
getParams() {
return radar
? { opacity }
: satellite
? { product, opacity, infrared }
: { product, opacity };
},
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
const missing =
noFrame &&
shared?.mode === 'history' &&
shared.products.find((entry) => entry.id === id)?.selected === null
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
: null;
const time = shownTime();
const relation =
shared?.mode === 'history' && time
? Date.parse(time) === Date.parse(shared.target)
? ' · synced'
: Date.parse(time) < Date.parse(shared.target)
? ' · nearest'
: ''
: '';
const times = manifest?.times || [];
const index = times.indexOf(time);
const current = index >= 0;
const age = time
? Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60000))
: null;
const lag =
age === null
? ''
: age >= 60
? `${Math.floor(age / 60)}h ${age % 60}m ago`
: `${age}m ago`;
const diagnostic = rendering?.getDiagnostics();
const b = manifest?.bounds;
const canvas = viewer?.scene?.canvas;
const point =
canvas &&
viewer.camera.pickEllipsoid?.(
new cesium.Cartesian2(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
),
viewer.scene.globe.ellipsoid,
);
const camera = point
? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid)
: null;
const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0;
const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0;
const outside =
b &&
camera &&
(lon < b.west ||
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
const controls = {
readout: true,
summary: {
label: radar
? 'Rain radar · US'
: lightning
? 'Lightning density · 15 min'
: 'Satellite clouds',
coverage: radar
? 'CONUS'
: lightning
? 'Americas + Pacific'
: product === 'clouds'
? 'Global · 60°S–60°N'
: 'North America',
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail: time
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
: missing
? 'Observation unavailable'
: 'Waiting for observation',
status:
missing ||
hostStatus ||
error ||
diagnostic?.error ||
(observationDelayed()
? 'Source observations delayed'
: manifest?.stale
? 'Stale source'
: loading
? 'Loading next frame…'
: outside
? 'Map center outside coverage'
: null),
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
},
chips: [
...(satellite
? [
['clouds-regional', 'N. America'],
['clouds', 'Global'],
].map(([value, label]) => ({
id: value,
label,
active: product === value,
params: { product: value },
title:
value === 'clouds'
? 'Hourly global mosaic; usually 2–3 hours delayed'
: 'GOES regional clouds; approximately 5-minute updates',
}))
: []),
...(satellite
? [
{
id: 'filtered',
label: 'Clouds only',
active: infrared === 'filtered',
params: { infrared: 'filtered' },
title:
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
},
{
id: 'full',
label: 'Full',
active: infrared === 'full',
params: { infrared: 'full' },
title: 'The complete infrared image at the chosen opacity',
},
]
: []),
...['light', 'strong'].map((value) => ({
id: `opacity-${value}`,
label: value === 'light' ? 'Soft' : 'Vivid',
active: opacity === value,
params: { opacity: value },
title: 'Image opacity; does not alter the observed values',
})),
{
id: 'coverage',
label: radar
? 'View US radar'
: lightning
? 'View Americas & Pacific'
: 'View coverage',
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? `${label} strikes/km²/min ×10³ (15-minute density)`
: `${label} dBZ radar reflectivity`,
}))
: [],
info: hostHidden
? hostStatus
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
infoTitle: lightning
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
};
controls.summary.settings = [
...(satellite
? [
{
id: 'region',
label: 'REGION',
chips: controls.chips.filter(({ params }) => params.product),
},
{
id: 'image',
label: 'IMAGE',
chips: controls.chips.filter(({ params }) => params.infrared),
},
]
: []),
{
id: 'opacity',
label: 'OPACITY',
chips: controls.chips.filter(({ params }) => params.opacity),
},
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'coverage',
);
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? 'Observed' : 'History',
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
observedAt: shownTime(),
};
},
getDiagnostics() {
const shared = clock?.getState();
return {
...rendering?.getDiagnostics(),
playing: shared ? shared.playing : playing && !hostHidden,
followLatest: isLatest(),
...(shared
? {
clock: {
mode: shared.mode,
target: shared.target,
playing: shared.playing,
},
}
: {}),
historyFrames: manifest?.times?.length || 0,
timerActive: timer !== null,
};
},
destroy() {
layer.disable();
unsubscribeClock?.();
removeCamera?.();
removeCamera = null;
motion?.removeEventListener?.('change', onVisibility);
documentRef?.removeEventListener?.('visibilitychange', onVisibility);
eventTarget?.removeEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
runNavigation = null;
imageryHost = null;
viewer = null;
rendering = null;
listener = null;
},
};
return layer;
}
+23
View File
@@ -0,0 +1,23 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
/** Return display pixels without changing RGB or the source RGBA bytes. */
export function infraredAlpha(rgba, mode = 'filtered') {
const pixels = new Uint8ClampedArray(rgba);
if (mode === 'full') return pixels;
for (let i = 0; i < pixels.length; i += 4) {
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
1,
(linearMax - INFRARED_ALPHA_T0) /
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
),
);
pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t);
}
return pixels;
}
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
infraredAlpha,
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
} from './infraredAlpha.js';
test('filtered infrared preserves RGB and input bytes, scales existing alpha, and matches the old linear threshold', () => {
assert.equal(INFRARED_ALPHA_T0, 0.4);
assert.equal(INFRARED_ALPHA_T1, 0.7);
const bytes = new Uint8ClampedArray([
0, 0, 0, 255, 168, 12, 50, 255, 12, 194, 50, 200, 12, 50, 217, 128, 255,
255, 255, 0,
]);
const original = bytes.slice();
const result = infraredAlpha(bytes);
assert.deepEqual(bytes, original);
for (let i = 0; i < bytes.length; i += 4)
assert.deepEqual(result.slice(i, i + 3), bytes.slice(i, i + 3));
assert.deepEqual(
[result[3], result[7], result[11], result[15], result[19]],
[0, 0, 98, 128, 0],
);
// Cesium's old 0.55 hard cut falls between byte values 194 and 195.
assert.ok((194 / 255) ** 2.2 < 0.55);
assert.ok((195 / 255) ** 2.2 > 0.55);
const ramp = [];
for (let value = 0; value < 256; value++) {
ramp.push(infraredAlpha([value, value, value, 255])[3]);
}
assert.ok(ramp[194] < 127.5 && ramp[195] > 127.5);
assert.ok(ramp.every((value, i) => !i || value >= ramp[i - 1]));
assert.ok(Math.max(...ramp.slice(1).map((v, i) => v - ramp[i])) <= 8);
assert.ok(
Math.abs(
(0.55 - INFRARED_ALPHA_T0) / (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0) -
0.5,
) < 1e-12,
);
});
test('full infrared preserves every channel including partial and zero alpha', () => {
const pixels = new Uint8ClampedArray([
12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0,
]);
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
});
+109
View File
@@ -0,0 +1,109 @@
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { weatherImageUrl } from './source.js';
import { infraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
/** Apply the display transfer once to a decoded image. */
export function processInfraredImage(image, mode, createCanvas) {
const canvas = createCanvas();
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
pixels.data.set(infraredAlpha(pixels.data, mode));
context.putImageData(pixels, 0, 0);
return canvas;
}
/** Decode locally, releasing the object URL on success, failure or cancellation. */
export function decodeInfraredImage(blob, signal) {
signal.throwIfAborted();
if (typeof globalThis.createImageBitmap === 'function')
return globalThis.createImageBitmap(blob);
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(blob);
const cleanup = () => {
signal.removeEventListener('abort', abort);
image.onload = image.onerror = null;
URL.revokeObjectURL(url);
};
const abort = () => {
cleanup();
image.src = '';
reject(signal.reason);
};
image.onload = () => {
cleanup();
resolve(image);
};
image.onerror = () => {
cleanup();
reject(new Error('Infrared decode failed'));
};
signal.addEventListener('abort', abort, { once: true });
image.src = url;
});
}
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
* image is always released. */
export async function acquireWeatherImage(
product,
time,
{
signal,
mode,
size,
bbox = null,
maxBytes,
createCanvas,
fetchImpl,
decodeImage = decodeInfraredImage,
now = () => performance.now(),
onFetched = () => {},
},
) {
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
signal,
});
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
const bytes = await readResponseBytesCapped(response, maxBytes);
signal.throwIfAborted();
onFetched();
const started = now();
const image = await decodeImage(
new Blob([bytes], { type: 'image/png' }),
signal,
);
try {
signal.throwIfAborted();
if (image.width !== size.width || image.height !== size.height)
throw new Error('Invalid weather image dimensions');
let texture;
if (product === 'clouds' || product === 'clouds-regional')
texture = processInfraredImage(image, mode, createCanvas);
else {
texture = createCanvas();
texture.width = image.width;
texture.height = image.height;
texture.getContext('2d').drawImage(image, 0, 0);
}
return { texture, decodeMs: now() - started };
} finally {
image.close?.();
}
}
/** Acquire one capped global mosaic before adding any imagery layer. */
export function acquireInfraredMosaic(time, options) {
return acquireWeatherImage('clouds', time, {
...options,
size: { width: 2048, height: 1024 },
maxBytes: MAX_MOSAIC_BYTES,
});
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
acquireInfraredMosaic,
decodeInfraredImage,
processInfraredImage,
MAX_MOSAIC_BYTES,
} from './infraredImage.js';
function canvas() {
const output = {};
output.getContext = () => ({
drawImage: (image) => {
output.image = image;
},
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
putImageData: (pixels) => {
output.pixels = pixels.data;
},
});
return output;
}
test('decoded pixels are processed once on a same-sized canvas', () => {
const image = { width: 1, height: 1 };
const output = processInfraredImage(image, 'filtered', canvas);
assert.equal(output.width, 1);
assert.equal(output.height, 1);
assert.equal(output.image, image);
assert.deepEqual([...output.pixels], [194, 0, 0, 98]);
assert.deepEqual(
[...processInfraredImage(image, 'full', canvas).pixels],
[194, 0, 0, 200],
);
});
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
const controller = new AbortController();
let closes = 0,
fetches = 0,
clock = 10,
fetched = false;
const options = {
signal: controller.signal,
mode: 'filtered',
createCanvas: canvas,
now: () => clock,
onFetched: () => {
fetched = true;
},
fetchImpl: async (url, { signal }) => {
fetches++;
assert.match(url, /^\/api\/weather\/image\?product=clouds&time=/);
assert.equal(signal, controller.signal);
return new Response(new Uint8Array([1, 2, 3]));
},
decodeImage: async (blob) => {
assert.equal(fetched, true);
assert.equal(blob.size, 3);
assert.equal(blob.type, 'image/png');
clock = 25;
return {
width: 2048,
height: 1024,
close() {
closes++;
},
};
},
};
const result = await acquireInfraredMosaic(
'2026-09-15T20:00:00.000Z',
options,
);
assert.equal(fetches, 1);
assert.equal(closes, 1);
assert.equal(result.decodeMs, 15);
assert.equal(result.texture.width, 2048);
for (const response of [
new Response('', {
headers: { 'content-length': String(MAX_MOSAIC_BYTES + 1) },
}),
new Response(new Uint8Array(MAX_MOSAIC_BYTES + 1)),
]) {
await assert.rejects(
acquireInfraredMosaic('2026-09-15', {
...options,
fetchImpl: async () => response,
decodeImage: () => assert.fail('oversized image must not decode'),
}),
{ code: 'RESPONSE_TOO_LARGE' },
);
}
await assert.rejects(
acquireInfraredMosaic('2026-09-15', {
...options,
decodeImage: async () => {
controller.abort();
return {
width: 2048,
height: 1024,
close() {
closes++;
},
};
},
}),
{ name: 'AbortError' },
);
assert.equal(closes, 2);
});
test('bitmap decoding and Image fallback release resources on success, error and abort', async (t) => {
const original = Object.getOwnPropertyDescriptor(
globalThis,
'createImageBitmap',
);
t.after(() => {
if (original)
Object.defineProperty(globalThis, 'createImageBitmap', original);
else delete globalThis.createImageBitmap;
});
const bitmap = {};
globalThis.createImageBitmap = async () => bitmap;
assert.equal(
await decodeInfraredImage(new Blob(), new AbortController().signal),
bitmap,
);
globalThis.createImageBitmap = undefined;
const images = [],
revoked = [];
const originalImage = globalThis.Image;
globalThis.Image = class {
constructor() {
images.push(this);
}
};
t.after(() => {
if (originalImage) globalThis.Image = originalImage;
else delete globalThis.Image;
});
t.mock.method(URL, 'createObjectURL', () => 'blob:test');
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
for (const action of ['load', 'error', 'abort']) {
const controller = new AbortController();
const pending = decodeInfraredImage(new Blob(), controller.signal);
const image = images.at(-1);
assert.equal(image.src, 'blob:test');
if (action === 'load') {
image.onload();
assert.equal(await pending, image);
} else if (action === 'error') {
image.onerror();
await assert.rejects(pending, /decode failed/);
} else {
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(image.src, '');
}
assert.equal(image.onload, null);
assert.equal(image.onerror, null);
}
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
});
+50
View File
@@ -0,0 +1,50 @@
/** Tile a bounded decoded canvas without network requests or scene state. */
export function createRasterTileProvider({
cesium,
texture,
credit,
createCanvas,
rectangle = cesium.Rectangle.MAX_VALUE,
tilingScheme = new cesium.GeographicTilingScheme({ rectangle }),
maximumLevel = 2,
tileSize = 256,
}) {
return {
tilingScheme,
rectangle,
tileWidth: tileSize,
tileHeight: tileSize,
minimumLevel: 0,
maximumLevel,
ready: true,
tileDiscardPolicy: undefined,
credit,
errorEvent: new cesium.Event(),
hasAlphaChannel: true,
getTileCredits: () => undefined,
pickFeatures: () => undefined,
requestImage(x, y, level) {
const tile = createCanvas();
tile.width = tile.height = tileSize;
const ctx = tile.getContext('2d');
const width =
texture.width / tilingScheme.getNumberOfXTilesAtLevel(level);
const height =
texture.height / tilingScheme.getNumberOfYTilesAtLevel(level);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(
texture,
x * width,
y * height,
width,
height,
0,
0,
tileSize,
tileSize,
);
// ImageryLayer consumes requestImage results as promises.
return Promise.resolve(tile);
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createRasterTileProvider } from './rasterTiles.js';
import { createFieldRaster } from '../wind/fields.js';
// A small canvas double exposes the uploaded raster and exact drawImage crop.
// Verify crop coordinates and source-cell selection independently of canvas smoothing.
function createCanvas() {
const canvas = { width: 0, height: 0 };
const context = {
createImageData: (width, height) => ({
data: new Uint8ClampedArray(width * height * 4),
}),
putImageData(pixels) {
canvas.rgba = pixels.data;
},
drawImage(source, ...args) {
canvas.source = source;
canvas.crop = args;
},
getImageData(x, y) {
const [sx, sy, sw, sh, dx, dy, dw, dh] = canvas.crop;
const col = Math.floor(sx + ((x - dx + 0.5) * sw) / dw);
const row = Math.floor(sy + ((y - dy + 0.5) * sh) / dh);
const offset = (row * canvas.source.width + col) * 4;
return { data: canvas.source.rgba.slice(offset, offset + 4) };
},
};
canvas.getContext = () => context;
return canvas;
}
test('real Cesium geographic provider crops level 0/1 tiles from a decoded canvas with preserved pixels', async () => {
const raster = createFieldRaster(
{
nx: 8,
ny: 3,
lo1: -180,
la1: 90,
dx: 45,
dy: 90,
u: Float32Array.from([
0, 0, 10, 10, 20, 20, 30, 30, 0, 0, 10, 10, 20, 20, 30, 30, 30, 30, 20,
20, 10, 10, 0, 0,
]),
v: new Float32Array(24),
},
'speed',
720,
362,
);
const credit = new Cesium.Credit('NOAA GFS');
const texture = {
width: raster.width,
height: raster.height,
rgba: raster.rgba,
};
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
credit,
createCanvas,
});
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
assert.equal(provider.rectangle, Cesium.Rectangle.MAX_VALUE);
assert.equal(provider.minimumLevel, 0);
assert.equal(provider.maximumLevel, 2);
assert.equal(provider.tileWidth, 256);
assert.equal(provider.tileHeight, 256);
assert.equal(provider.ready, true);
assert.equal(provider.hasAlphaChannel, true);
assert.equal(provider.credit, credit);
assert.ok(provider.errorEvent instanceof Cesium.Event);
assert.equal(provider.tileDiscardPolicy, undefined);
assert.equal(provider.getTileCredits(0, 0, 0), undefined);
assert.equal(provider.pickFeatures(0, 0, 0, 0, 0), undefined);
for (const [x, y, level, crop, cell] of [
[0, 0, 0, [0, 0, 360, 362, 0, 0, 256, 256], [90, 91]],
[1, 0, 0, [360, 0, 360, 362, 0, 0, 256, 256], [450, 91]],
[0, 0, 1, [0, 0, 180, 181, 0, 0, 256, 256], [45, 45]],
[3, 1, 1, [540, 181, 180, 181, 0, 0, 256, 256], [585, 226]],
]) {
const pending = provider.requestImage(x, y, level);
assert.ok(pending instanceof Promise, 'ImageryLayer consumes a promise');
const tile = await pending;
assert.equal(tile.width, 256);
assert.equal(tile.height, 256);
assert.equal(tile.getContext('2d').imageSmoothingEnabled, true);
assert.deepEqual(tile.crop, crop);
const offset = (cell[1] * 720 + cell[0]) * 4;
assert.deepEqual(
tile.getContext('2d').getImageData(64, 64).data,
raster.rgba.slice(offset, offset + 4),
);
}
});
test('bounded decoded mosaic crops both roots and higher levels without resampling its extent', async () => {
const texture = { width: 2048, height: 1024 };
const rectangle = Cesium.Rectangle.fromDegrees(-180, -60, 180, 60);
const scheme = new Cesium.GeographicTilingScheme({
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
});
const credit = new Cesium.Credit('NOAA');
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
rectangle,
tilingScheme: scheme,
maximumLevel: 3,
credit,
createCanvas,
});
assert.equal(provider.rectangle, rectangle);
assert.equal(provider.maximumLevel, 3);
assert.equal(provider.credit, credit);
assert.equal(provider.hasAlphaChannel, true);
for (const [x, y, level, crop] of [
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 256, 256]],
[1, 0, 0, [1024, 0, 1024, 1024, 0, 0, 256, 256]],
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 256, 256]],
]) {
const pending = provider.requestImage(x, y, level);
assert.ok(pending instanceof Promise);
const tile = await pending;
assert.equal(tile.source, texture);
assert.deepEqual(tile.crop, crop);
}
});
test('512-pixel crops preserve source extent at root and clamped draping levels', async () => {
const texture = { width: 2048, height: 1024 };
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
createCanvas,
tileSize: 512,
maximumLevel: 3,
});
assert.equal(provider.tileWidth, 512);
assert.equal(provider.tileHeight, 512);
for (const [x, y, level, crop] of [
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 512, 512]],
[3, 1, 1, [1536, 512, 512, 512, 0, 0, 512, 512]],
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 512, 512]],
]) {
const tile = await provider.requestImage(x, y, level);
assert.equal(tile.width, 512);
assert.equal(tile.height, 512);
assert.equal(tile.source, texture);
assert.deepEqual(tile.crop, crop);
}
});
+667
View File
@@ -0,0 +1,667 @@
import { weatherTileUrl } from './source.js';
import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js';
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import {
acquireInfraredMosaic,
processInfraredImage,
} from './infraredImage.js';
// Bounded display detail for the hourly, approximately 3 km global product.
const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
const MAX_MOSAICS = 6;
const MAX_PREFETCH_TILES = 8;
/** Globe imagery: own at most a displayed and a staging frame. Use native Cesium
* tile scheduling, projection and texture disposal; the application clock is never touched. */
function createGlobeRendering({
viewer,
cesium,
getHost,
onChange,
timeoutMs,
now,
fetchImpl,
decodeImage,
createCanvas,
}) {
let current = null;
let incoming = null;
const retiring = new Set();
let alpha = 0.7;
let frameHidden = false;
let lastError = null;
const mosaics = new Map();
let prefetchJob = null;
let prefetchedKey = null;
function profile(product) {
return {
tileSize: 256,
maximumLevel: product === 'clouds' ? GLOBAL_TILE_MAXIMUM_LEVEL : 6,
};
}
function cancelPrefetch() {
clearTimeout(prefetchJob?.timeout);
prefetchJob?.controller.abort();
prefetchJob = null;
prefetchedKey = null;
}
async function mosaic(time, mode, signal, onFetched) {
signal.throwIfAborted();
const key = `${time}|${mode}`;
const texture = mosaics.get(key);
if (texture) {
mosaics.delete(key);
mosaics.set(key, texture);
return { texture, decodeMs: 0, cached: true };
}
const result = await acquireInfraredMosaic(time, {
signal,
mode,
createCanvas,
fetchImpl,
decodeImage,
now,
onFetched,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
mosaics.delete(key);
mosaics.set(key, result.texture);
while (mosaics.size > MAX_MOSAICS)
mosaics.delete(mosaics.keys().next().value);
return { ...result, cached: false };
}
function prefetchTiles(snapshot, time) {
const { west, south, east, north } = snapshot.bounds;
const bounds = cesium.Rectangle.fromDegrees(west, south, east, north);
const view = viewer.camera?.computeViewRectangle?.(
viewer.scene.globe?.ellipsoid,
);
const coverage = view
? cesium.Rectangle.intersection(bounds, view)
: bounds;
if (!coverage) return [];
const scheme = new cesium.GeographicTilingScheme();
const template = weatherTileUrl(snapshot.product, time, {
size: profile(snapshot.product).tileSize,
});
const urls = [];
for (let z = 0; z <= 1; z++) {
for (let y = 0; y < scheme.getNumberOfYTilesAtLevel(z); y++) {
for (let x = 0; x < scheme.getNumberOfXTilesAtLevel(z); x++) {
if (
!cesium.Rectangle.intersection(
coverage,
scheme.tileXYToRectangle(x, y, z),
)
)
continue;
urls.push(
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
);
if (urls.length === MAX_PREFETCH_TILES) return urls;
}
}
}
return urls;
}
function remove(frame) {
if (!frame) return;
frame.closed = true;
frame.controller.abort();
frame.offRetire?.();
frame.offInstall?.();
retiring.delete(frame);
frame.offError?.();
frame.offAbort?.();
frame.offRender?.();
frame.offCamera?.();
clearTimeout(frame.timeout);
for (const request of frame.requests) request.cancel?.();
frame.requests.clear();
frame.retries.clear();
if (!frame.layer) return;
const collection = frame.collection;
if (
collection &&
!collection.isDestroyed?.() &&
collection.contains(frame.layer)
)
collection.remove(frame.layer, true);
else if (!frame.layer.isDestroyed?.()) frame.layer.destroy?.();
}
function cancelIncoming() {
if (!incoming) return;
const previous = incoming;
incoming = null;
remove(previous);
previous.resolve(false);
}
function rehome() {
const host = getHost();
const { collection } = host;
const hidden = frameHidden || imageryHostStatus(host) !== null;
const changed =
(current && current.collection !== collection) ||
(incoming && incoming.collection !== collection);
const visibilityChanged = current && current.layer.show === hidden;
if (changed || imageryHostStatus(host) !== null) {
cancelPrefetch();
cancelIncoming();
for (const frame of retiring) remove(frame);
}
if (current) {
if (!hidden && current.layer.alpha !== alpha) current.layer.alpha = alpha;
if (visibilityChanged) current.layer.show = !hidden;
}
if (current && current.collection !== collection) {
current.collection?.remove(current.layer, false);
current.collection = collection;
if (collection) {
collection.add(current.layer);
orderWeatherImagery(collection, current.layer, current.priority);
}
}
if (changed || visibilityChanged) viewer.scene.requestRender();
return Boolean(changed || visibilityChanged);
}
return {
rehome,
cancelPrefetch,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job;
try {
if (
frameHidden ||
incoming ||
imageryHostStatus(getHost()) ||
!snapshot.times.includes(time)
)
return false;
const global = snapshot.product === 'clouds';
const urls = global ? [] : prefetchTiles(snapshot, time);
const key = global ? `${time}|${infrared}` : urls.join('|');
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
prefetchJob = job;
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
if (global) await mosaic(time, infrared, signal);
else
await Promise.all(
urls.map(async (url) => {
const response = await fetchImpl(url, { signal });
if (!response.ok) throw new Error('Weather prefetch unavailable');
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
}),
);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
return true;
} catch {
job?.controller.abort();
return false;
} finally {
clearTimeout(job?.timeout);
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
signal?.throwIfAborted();
cancelPrefetch();
rehome();
cancelIncoming();
const host = getHost();
const { collection, kind } = host;
if (imageryHostStatus(host)) return false;
const nextProfile = profile(snapshot.product);
if (
current?.time === time &&
current.product === snapshot.product &&
current.kind === kind &&
current.infrared === infrared
)
return true;
lastError = null;
const { west, south, east, north } = snapshot.bounds;
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
const global = snapshot.product === 'clouds';
const frame = {
snapshot,
time,
infrared,
mosaic: global
? { fetched: false, decodeMs: null, cached: false }
: undefined,
controller: new AbortController(),
collection,
kind,
priority:
snapshot.product === 'lightning'
? 3
: snapshot.product === 'radar'
? 2
: 1,
product: snapshot.product,
requests: new Set(),
retries: new Map(),
deferred: new Set(),
pending: 0,
loaded: 0,
lastActivity: now(),
startedAt: now(),
closed: false,
failed: false,
resolve: null,
};
incoming = frame;
const result = new Promise((resolve) => {
frame.resolve = resolve;
});
const finish = (ok) => {
if (incoming !== frame) return;
incoming = null;
frame.offRender?.();
frame.offRender = null;
frame.offCamera?.();
frame.offCamera = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
if (ok) {
lastError = null;
const previous = current;
current = frame;
frame.loadMs = now() - frame.startedAt;
frame.layer.alpha = alpha;
frame.layer.show = !frameHidden;
viewer.scene.requestRender();
if (previous) {
retiring.add(previous);
previous.offRetire = viewer.scene.postRender.addEventListener(
() => {
remove(previous);
viewer.scene.requestRender();
},
);
}
} else {
lastError = 'Weather tiles unavailable · previous frame retained';
remove(frame);
}
frame.resolve(ok);
viewer.scene.requestRender();
onChange();
};
const abort = () => {
if (incoming === frame) cancelIncoming();
viewer.scene.requestRender();
};
signal?.addEventListener('abort', abort, { once: true });
frame.offAbort = () => signal?.removeEventListener('abort', abort);
frame.timeout = setTimeout(() => finish(false), timeoutMs);
const install = (texture) => {
if (frame.closed) return;
// Keep at most two installed layers during rapid successive selections.
if (retiring.size) {
frame.offInstall = viewer.scene.postRender.addEventListener(() => {
frame.offInstall();
frame.offInstall = null;
try {
install(texture);
} catch {
finish(false);
}
});
viewer.scene.requestRender();
return;
}
const tilingScheme = new cesium.GeographicTilingScheme(
global
? {
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
}
: undefined,
);
const credit = new cesium.Credit(
snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
false,
);
const provider = global
? createRasterTileProvider({
cesium,
texture,
rectangle,
tilingScheme,
maximumLevel: GLOBAL_TILE_MAXIMUM_LEVEL,
tileSize: nextProfile.tileSize,
credit,
createCanvas,
})
: new cesium.UrlTemplateImageryProvider({
url: weatherTileUrl(snapshot.product, time, {
size: nextProfile.tileSize,
}),
tilingScheme,
rectangle,
tileWidth: nextProfile.tileSize,
tileHeight: nextProfile.tileSize,
maximumLevel: nextProfile.maximumLevel,
enablePickFeatures: false,
credit,
});
const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (frame.closed) return undefined;
const result = requestImage(x, y, level, request);
const tileKey = `${level}/${x}/${y}`;
if (!result) {
// Scheduler admission is part of readiness, not a successful tile.
frame.deferred.add(tileKey);
frame.lastActivity = now();
return result;
}
frame.deferred.delete(tileKey);
frame.pending++;
frame.lastActivity = now();
if (request) frame.requests.add(request);
return Promise.resolve(result)
.then((image) => {
if (!frame.closed && snapshot.product === 'clouds-regional')
image = processInfraredImage(image, infrared, createCanvas);
frame.retries.delete(tileKey);
frame.loaded++;
return image;
})
.finally(() => {
frame.pending--;
frame.lastActivity = now();
frame.requests.delete(request);
if (!frame.closed) viewer.scene.requestRender();
});
};
frame.offError = provider.errorEvent.addEventListener((error) => {
if (frame.closed) return;
const status = error?.error?.statusCode;
// Cesium retries synchronously after this event; no delay hook is exposed.
const tileKey = `${error.level}/${error.x}/${error.y}`;
const retries = frame.retries.get(tileKey) ?? 0;
error.retry = false;
if ((status === 429 || status === 503) && retries < 3) {
frame.retries.set(tileKey, retries + 1);
error.retry = true;
return;
}
frame.failed = true;
lastError = 'Some weather tiles unavailable';
onChange();
});
// A shown, transparent layer lets Cesium request staging tiles while the last
// complete observation stays visible underneath it.
frame.layer = collection.addImageryProvider(provider);
frame.layer.alpha = 0;
orderWeatherImagery(collection, frame.layer, frame.priority);
let settled = 0;
frame.offCamera = viewer.camera?.moveEnd?.addEventListener(() => {
// Tiles abandoned by a previous viewport are no longer admission work.
frame.deferred.clear();
frame.lastActivity = now();
settled = 0;
viewer.scene.requestRender();
});
frame.offRender = viewer.scene.postRender.addEventListener(() => {
if (frame.failed) return finish(false);
// Unrelated terrain/basemap work must not indefinitely hold a ready
// observation. Require successful own tiles and a quiet scheduling
// interval before admitting a frame when the rest of the globe is busy.
const ownReady =
frame.loaded > 0 &&
frame.deferred.size === 0 &&
now() - frame.lastActivity >= 200;
if (
((frame.kind === 'globe' && viewer.scene.globe.tilesLoaded) ||
ownReady) &&
frame.pending === 0
) {
if (++settled >= 2) finish(true);
else viewer.scene.requestRender();
} else settled = 0;
if (incoming === frame && frame.pending === 0 && frame.loaded > 0)
viewer.scene.requestRender();
});
viewer.scene.requestRender();
onChange();
};
if (global) {
void mosaic(time, infrared, frame.controller.signal, () => {
frame.mosaic.fetched = true;
})
.then(({ texture, decodeMs, cached }) => {
frame.mosaic.decodeMs = decodeMs;
frame.mosaic.cached = cached;
install(texture);
})
.catch(() => finish(false));
} else {
try {
install();
} catch {
finish(false);
}
}
viewer.scene.requestRender();
onChange();
return result;
},
setHidden(value) {
frameHidden = Boolean(value);
if (frameHidden) {
cancelPrefetch();
cancelIncoming();
for (const frame of retiring) remove(frame);
}
rehome();
viewer.scene.requestRender();
},
setAlpha(value) {
alpha = value;
if (current && current.layer.show) current.layer.alpha = alpha;
viewer.scene.requestRender();
},
clear() {
cancelPrefetch();
cancelIncoming();
remove(current);
for (const frame of retiring) remove(frame);
current = null;
frameHidden = false;
lastError = null;
mosaics.clear();
viewer.scene.requestRender();
},
getDiagnostics() {
return {
host: 'globe',
cache: { mosaics: mosaics.size, prefetching: !!prefetchJob },
imageryCount:
Number(!!current) + Number(!!incoming?.layer) + retiring.size,
mosaic: (incoming || current)?.mosaic,
infrared: (incoming || current)?.infrared ?? 'filtered',
loading: !!incoming,
time: frameHidden ? null : (current?.time ?? null),
hidden: frameHidden,
product: current?.product ?? null,
pendingTiles: incoming?.pending ?? 0,
deferredTiles: incoming?.deferred.size ?? 0,
loadedTiles: (incoming || current)?.loaded ?? 0,
frameLoadMs: current?.loadMs ?? null,
error: imageryHostStatus(getHost()) || lastError,
};
},
};
}
/** Globe hosts keep draped imagery; 3D Tiles hosts get a raised shell per product.
* A host switch tears one renderer down and restages the last frame on the other. */
export function createWeatherRendering({
viewer,
cesium,
getHost = () => ({ collection: viewer.imageryLayers, kind: 'globe' }),
onChange = () => {},
timeoutMs = 25_000,
now = () => performance.now(),
fetchImpl = (...args) => globalThis.fetch(...args),
decodeImage,
createCanvas = () => document.createElement('canvas'),
createShell = createWeatherShell,
}) {
const shared = {
viewer,
cesium,
getHost,
onChange,
timeoutMs,
now,
fetchImpl,
decodeImage,
createCanvas,
};
const globe = createGlobeRendering(shared);
let shell = null;
let active = null;
let alpha = 0.7;
let hidden = false;
let shown = null;
let restaging = null;
// A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => {
const { kind } = getHost();
return kind === 'tileset'
? 'shell'
: kind === 'globe'
? 'globe'
: (active ?? 'globe');
};
const renderer = () => (active === 'shell' ? shell : globe);
function shellFor(product) {
if (shell?.product === product) return shell;
shell?.clear();
shell = createShell({ ...shared, product });
shell.setAlpha(alpha);
shell.setHidden(hidden);
return shell;
}
function restage(frame) {
const target =
active === 'shell' ? shellFor(frame.snapshot.product) : globe;
restaging = frame;
const settle = (ok) => {
if (restaging !== frame) return;
restaging = null;
if (!ok) onChange();
};
target
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
.then(settle, () => settle(false));
}
function switchHost() {
const next = wanted();
if (next === active) return false;
const previous = active;
active = next;
if (previous === null) return false;
restaging = null;
if (previous === 'shell') {
shell?.clear();
shell = null;
} else globe.clear();
if (active === 'globe') {
globe.setAlpha(alpha);
if (hidden) globe.setHidden(true);
}
if (shown && !hidden) restage(shown);
viewer.scene.requestRender();
return true;
}
return {
rehome() {
const switched = switchHost();
if (active === 'shell' && !shell) return switched;
return renderer().rehome() || switched;
},
cancelPrefetch() {
renderer()?.cancelPrefetch();
},
prefetch(snapshot, time, options) {
switchHost();
if (active === 'shell' && shell?.product !== snapshot.product)
return Promise.resolve(false);
return renderer().prefetch(snapshot, time, options);
},
async setFrame(snapshot, time, options = {}) {
options.signal?.throwIfAborted();
switchHost();
restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const ok = await target.setFrame(snapshot, time, options);
if (ok)
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
return ok;
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) restaging = null;
renderer()?.setHidden(hidden);
},
setAlpha(value) {
alpha = value;
renderer()?.setAlpha(value);
},
clear() {
restaging = null;
shown = null;
hidden = false;
shell?.clear();
shell = null;
globe.clear();
},
getDiagnostics() {
const diagnostics =
active === 'shell' && shell
? shell.getDiagnostics()
: active === 'shell'
? {
host: 'shell',
cache: { mosaics: 0, bytes: 0, prefetching: false },
imageryCount: 0,
infrared: 'filtered',
loading: false,
time: null,
hidden,
product: null,
frameLoadMs: null,
error: imageryHostStatus(getHost()),
}
: globe.getDiagnostics();
// A host switch restages the retained frame; keep reporting its time.
return restaging && !hidden && diagnostics.time === null
? { ...diagnostics, time: restaging.time }
: diagnostics;
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import * as Cesium from 'cesium';
/** Test double for the Cesium classes a weather shell constructs. */
export function createShellCesium({ maximumTextureSize = 0 } = {}) {
const templates = new Map();
const created = { materials: [], primitives: [], appearances: [] };
class Material {
constructor(options) {
const template = templates.get(options.fabric.type);
this.options = options;
this.uniforms = {
...template.fabric.uniforms,
imageDimensions: { type: 'ivec3', x: 1, y: 1 },
detailDimensions: { type: 'ivec3', x: 1, y: 1 },
};
this.destroyed = false;
created.materials.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
Material.DefaultImageId = Cesium.Material.DefaultImageId;
Material._materialCache = {
getMaterial: (type) => templates.get(type),
addMaterial: (type, template) => templates.set(type, template),
};
class Primitive {
constructor(options) {
this.options = options;
this.appearance = options.appearance;
this.show = options.show ?? true;
this.ready = false;
this.destroyed = false;
created.primitives.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
this.destroyed = true;
}
}
class EllipsoidSurfaceAppearance {
constructor(options) {
this.options = options;
this.material = options.material;
created.appearances.push(this);
}
}
EllipsoidSurfaceAppearance.VERTEX_FORMAT =
Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT;
class RectangleGeometry {
constructor(options) {
this.options = options;
}
}
class GeometryInstance {
constructor(options) {
this.geometry = options.geometry;
}
}
return {
Material,
Primitive,
EllipsoidSurfaceAppearance,
RectangleGeometry,
GeometryInstance,
BlendingState: Cesium.BlendingState,
Cartesian4: Cesium.Cartesian4,
Math: Cesium.Math,
Rectangle: Cesium.Rectangle,
ContextLimits: { maximumTextureSize },
created,
templates,
};
}
/** A scene with an ordered primitive collection that destroys on remove. */
export function createShellScene() {
const items = [];
const listeners = new Set();
let renders = 0;
return {
primitives: {
items,
add(primitive) {
items.push(primitive);
return primitive;
},
remove(primitive) {
const index = items.indexOf(primitive);
if (index < 0) return false;
items.splice(index, 1);
primitive.destroy();
return true;
},
contains: (primitive) => items.includes(primitive),
get: (index) => items[index],
get length() {
return items.length;
},
lowerToBottom(primitive) {
const index = items.indexOf(primitive);
if (index >= 0) items.unshift(...items.splice(index, 1));
},
isDestroyed: () => false,
},
postRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
emit() {
for (const listener of [...listeners]) listener();
},
get size() {
return listeners.size;
},
},
requestRender() {
renders++;
},
get renders() {
return renders;
},
};
}
/** Render: finish asynchronous geometry, upload each shown material's images.
* An image uniform back at the default binds Cesium's 1×1 texture. */
export function renderShells(cesium, scene, count = 4) {
for (let i = 0; i < count; i++) {
for (const primitive of cesium.created.primitives) {
if (primitive.destroyed || !primitive.show) continue;
primitive.ready = true;
const { uniforms } = primitive.appearance.material;
for (const name of ['image', 'detail']) {
const dimensions = uniforms[`${name}Dimensions`];
if (!dimensions) continue;
const bound =
typeof uniforms[name] === 'object'
? uniforms[name]
: { width: 1, height: 1 };
dimensions.x = bound.width;
dimensions.y = bound.height;
}
}
scene.postRender.emit();
}
}
+867
View File
@@ -0,0 +1,867 @@
import { acquireWeatherImage } from './infraredImage.js';
import { imageryHostStatus } from './imageryHost.js';
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
wind: 5_000,
clouds: 5_500,
'clouds-regional': 5_800,
radar: 6_200,
lightning: 6_600,
});
// Decoded canvases per renderer, full-extent and detail images alike: the shown
// frame and the warmed next frame, each full-extent and detail (four 4096×2048
// canvases); nothing older survives them. Cesium holds the shown canvases
// through the material uniforms in any case.
export const WEATHER_SHELL_CACHE_BYTES = 128 * 1024 * 1024;
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
const MATERIAL_TYPE = 'WeatherFrame';
// Renders after an image swap: one queues the upload, one uploads and draws, one spare.
const UPLOAD_FRAMES = 3;
// An empty detail window: the full-extent image shows everywhere.
const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
// Detail windows, in degrees.
const DETAIL_MIN_WIDTH = 6;
const DETAIL_GRID = 0.5;
const shellHeights = new WeakMap();
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
// draw nothing until the full-extent image has arrived, and sample the detail
// image only once it has arrived. The window is west, south, east, north in the
// full-extent image's texture coordinates.
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
vec2 st = materialInput.st;
vec4 coarse = texture(image, st);
vec2 dst = (st - window.xy) / max(window.zw - window.xy, vec2(1e-6));
float inside = float(all(greaterThanEqual(st, window.xy)) && all(lessThanEqual(st, window.zw)));
float useDetail = inside * step(1.5, float(detailDimensions.x));
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
material.diffuse = czm_gammaCorrect(c.rgb);
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
return material;
}
`;
function registerMaterial(cesium) {
const cache = cesium.Material._materialCache;
if (cache.getMaterial(MATERIAL_TYPE)) return;
// A plain template: building the first material from a fabric would make that
// instance Cesium's shared template and keep its last image alive.
cache.addMaterial(MATERIAL_TYPE, {
fabric: {
type: MATERIAL_TYPE,
uniforms: {
image: cesium.Material.DefaultImageId,
detail: cesium.Material.DefaultImageId,
alpha: 1,
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
},
// Diffuse and alpha share these samples, so one source, not components.
source: MATERIAL_SOURCE,
},
translucent: false,
});
}
const sameEdges = (a, b) =>
a.west === b.west &&
a.south === b.south &&
a.east === b.east &&
a.north === b.north;
/** Keep weather shells first in the primitive collection, in ascending height:
* higher shells draw over lower ones and other opaque content draws over both. */
export function orderWeatherShells(primitives, primitive, height) {
shellHeights.set(primitive, height);
if (typeof primitives.lowerToBottom !== 'function') return;
const shells = [];
for (let i = 0; i < primitives.length; i++) {
const item = primitives.get(i);
if (shellHeights.has(item)) shells.push(item);
}
const ordered = [...shells].sort(
(a, b) => shellHeights.get(a) - shellHeights.get(b),
);
if (ordered.every((item, i) => primitives.get(i) === item)) return;
for (const item of ordered.reverse()) primitives.lowerToBottom(item);
}
/** One raised rectangle drawing one full-extent image and, inside a window of
* it, an optional detail image. The owner calls destroy(). */
export function createShellSurface({
viewer,
cesium,
rectangle,
height,
onSettled = () => {},
}) {
registerMaterial(cesium);
const scene = viewer.scene;
const primitives = scene.primitives;
const material = new cesium.Material({
fabric: { type: MATERIAL_TYPE },
translucent: false,
});
// Flat shading keeps the product colours independent of the viewing angle.
const appearance = new cesium.EllipsoidSurfaceAppearance({
aboveGround: true,
flat: true,
translucent: false,
material,
});
// Cesium orders its translucent pass itself (weighted OIT or distance), which
// cannot honour stacked heights. Draw in the opaque pass in scene.primitives
// order, alpha-blended and without depth writes, so higher shells draw over
// lower ones and nothing is hidden or picked through a shell.
appearance.getRenderState = () => ({
depthTest: { enabled: true },
depthMask: false,
blending: cesium.BlendingState.ALPHA_BLEND,
});
const primitive = new cesium.Primitive({
geometryInstances: new cesium.GeometryInstance({
geometry: new cesium.RectangleGeometry({
rectangle,
height,
granularity: cesium.Math.toRadians(0.5),
vertexFormat: cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
}),
}),
appearance,
asynchronous: true,
allowPicking: false,
});
primitives.add(primitive);
orderWeatherShells(primitives, primitive, height);
let image = null;
let frames = 0;
// The detail image, its own upload count and a window waiting for it to draw.
let detail = null;
let detailFrames = 0;
let pendingWindow = null;
let offRender = null;
let destroyed = false;
const fits = (size, value) =>
!size || (size.x === value.width && size.y === value.height);
const uploaded = () =>
image !== null && fits(material.uniforms.imageDimensions, image);
const detailDrawn = () =>
detail !== null &&
detailFrames === 0 &&
fits(material.uniforms.detailDimensions, detail);
const drawn = () =>
primitive.ready &&
uploaded() &&
frames === 0 &&
(detail === null || (detailDrawn() && pendingWindow === null));
function appliedWindow() {
const { x, y, z, w } = material.uniforms.window;
return z < x ? null : { west: x, south: y, east: z, north: w };
}
function setWindow(rect) {
const next = rect ?? NO_WINDOW;
const { x, y, z, w } = material.uniforms.window;
if (
x === next.west &&
y === next.south &&
z === next.east &&
w === next.north
)
return;
material.uniforms.window = new cesium.Cartesian4(
next.west,
next.south,
next.east,
next.north,
);
scene.requestRender();
}
// The render governor may idle the scene; request frames only until the
// asynchronous geometry is ready and the latest images have been drawn.
function tick() {
// Cesium updates a material only while its primitive is shown, so hidden
// frames upload nothing and count for nothing.
if (primitive.ready && primitive.show) {
if (frames > 0) frames--;
if (detailFrames > 0) detailFrames--;
// A texture is never shown at another window's place.
if (pendingWindow && detailDrawn()) {
setWindow(pendingWindow);
pendingWindow = null;
}
}
if (primitive.show && !drawn()) {
scene.requestRender();
return;
}
offRender?.();
offRender = null;
if (drawn()) onSettled();
}
function wake() {
if (destroyed || !primitive.show || image === null || drawn()) return;
offRender ??= scene.postRender.addEventListener(tick);
scene.requestRender();
}
return {
setImage(next) {
if (destroyed || next === image) return;
image = next;
material.uniforms.image = next;
frames = UPLOAD_FRAMES;
wake();
},
setAlpha(value) {
if (destroyed || material.uniforms.alpha === value) return;
material.uniforms.alpha = value;
scene.requestRender();
},
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
* image's texture coordinates, 0–1); null clears it. In the same window only
* the image changes, and Cesium keeps drawing the previous texture until
* this one is uploaded. A different window applies once its image has
* drawn; until then the full-extent image covers it. */
setDetail(next, rect) {
if (destroyed) return;
if (!next) {
if (detail === null) return;
detail = null;
detailFrames = 0;
pendingWindow = null;
material.uniforms.detail = cesium.Material.DefaultImageId;
setWindow(null);
scene.requestRender();
return;
}
if (next !== detail) {
detail = next;
material.uniforms.detail = next;
detailFrames = UPLOAD_FRAMES;
}
const applied = appliedWindow();
if (applied && sameEdges(applied, rect)) {
pendingWindow = null;
} else {
setWindow(null);
pendingWindow = rect;
}
wake();
},
/** Returns whether visibility changed. */
setShow(value) {
const show = Boolean(value);
if (destroyed || primitive.show === show) return false;
primitive.show = show;
wake();
scene.requestRender();
return true;
},
destroy() {
if (destroyed) return;
destroyed = true;
offRender?.();
offRender = null;
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
primitives.remove(primitive);
if (!primitive.isDestroyed()) primitive.destroy();
// Primitive.destroy leaves the material and its texture alive.
if (!material.isDestroyed()) material.destroy();
image = null;
detail = null;
pendingWindow = null;
scene.requestRender();
},
getDiagnostics() {
return {
height,
ready: Boolean(primitive.ready),
uploaded: uploaded(),
show: primitive.show,
alpha: material.uniforms.alpha,
rendering: offRender !== null,
// `uploaded` once the detail image has drawn; the window applies after.
detail: { uploaded: detailDrawn(), window: appliedWindow() },
};
},
};
}
function fitTexture(cesium, { width, height }) {
const limit = cesium.ContextLimits?.maximumTextureSize;
// Halve, down to 1024 px wide, on devices with a smaller texture limit.
while (limit > 0 && width > limit && width > 1024) {
width /= 2;
height /= 2;
}
return { width, height };
}
/** The detail window for a view footprint over product bounds (both in degrees;
* a footprint across the antimeridian has west > east), or null when the
* full-extent image is already the best available. The window is centred on
* the footprint, max(2 × its longitude span, 6°) wide and half as tall, with
* edges on a 0.5° grid inside the bounds. It is enabled only when narrower than
* half the product's longitude extent and when the footprint overlaps the
* product. `previous` is returned unchanged while the footprint centre stays in
* its inner half and the wanted width is within 50 % of its width. */
export function detailWindow(footprint, bounds, previous = null) {
if (!footprint || !bounds) return null;
const across = footprint.east < footprint.west;
const span = footprint.east - footprint.west + (across ? 360 : 0);
let lon = footprint.west + span / 2;
if (lon > 180) lon -= 360;
const lat = (footprint.south + footprint.north) / 2;
const wanted = Math.max(2 * span, DETAIL_MIN_WIDTH);
if (previous) {
const width = previous.east - previous.west;
const height = previous.north - previous.south;
if (
Math.abs(lon - (previous.west + previous.east) / 2) <= width / 4 &&
Math.abs(lat - (previous.south + previous.north) / 2) <= height / 4 &&
Math.abs(wanted - width) <= width / 2
)
return previous;
}
const inner = {
west: Math.ceil(bounds.west / DETAIL_GRID) * DETAIL_GRID,
south: Math.ceil(bounds.south / DETAIL_GRID) * DETAIL_GRID,
east: Math.floor(bounds.east / DETAIL_GRID) * DETAIL_GRID,
north: Math.floor(bounds.north / DETAIL_GRID) * DETAIL_GRID,
};
// Whole degrees keep both the edges and the 2:1 height on the grid; the
// tolerance absorbs radian round trips.
const width = Math.ceil(wanted - 1e-9);
const height = width / 2;
const overlaps =
footprint.south < bounds.north &&
footprint.north > bounds.south &&
(across
? footprint.west < bounds.east || footprint.east > bounds.west
: footprint.west < bounds.east && footprint.east > bounds.west);
if (
!overlaps ||
width >= (bounds.east - bounds.west) / 2 ||
width > inner.east - inner.west ||
height > inner.north - inner.south
)
return null;
const snap = (value) => Math.round(value / DETAIL_GRID) * DETAIL_GRID;
const west = Math.min(
Math.max(snap(lon - width / 2), inner.west),
inner.east - width,
);
const south = Math.min(
Math.max(snap(lat - height / 2), inner.south),
inner.north - height,
);
const next = { west, south, east: west + width, north: south + height };
return previous && sameEdges(previous, next) ? previous : next;
}
/** Observed weather on 3D Tiles: one raised shell per product and one
* full-extent image per frame. The previous image stays until the next is ready.
* Inside a window around the view the same surface samples a sharper image of
* that area, so the two images share one mesh and never blend. */
export function createWeatherShell({
viewer,
cesium,
product,
height = WEATHER_SHELL_HEIGHTS[product],
getHost = () => ({ collection: null, kind: 'tileset' }),
onChange = () => {},
timeoutMs = 25_000,
now = () => performance.now(),
fetchImpl = (...args) => globalThis.fetch(...args),
decodeImage,
createCanvas = () => document.createElement('canvas'),
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
}) {
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
throw new TypeError('Unknown weather product');
const scene = viewer.scene;
const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]);
const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE);
const infrared = product === 'clouds' || product === 'clouds-regional';
// Global infrared contrast depends on the requested extent, so a window would
// not match the image around it; the globe host also shows one mosaic.
const detailed = product !== 'clouds';
let surface = null;
let current = null;
let incoming = null;
let alpha = 0.7;
let hidden = false;
let lastError = null;
const images = new Map();
let imageBytes = 0;
let prefetchJob = null;
let prefetchedKey = null;
// The detail window for the shown bounds, the image drawn in it and its fetch.
let view = null;
let viewBounds = null;
let detail = null;
let detailJob = null;
let offCamera = null;
const suspended = () => imageryHostStatus(getHost()) !== null;
const visible = () => !hidden && !suspended();
const boundsKey = ({ west, south, east, north }) =>
`${west},${south},${east},${north}`;
const cacheKey = (time, mode, box = null) =>
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
const wantedDetail = () =>
current && view ? cacheKey(current.time, current.infrared, view) : null;
function evict(keep = []) {
const kept = new Set([...keep, current?.key, detail?.key]);
for (const [key, entry] of images) {
if (imageBytes <= cacheBytes) return;
if (kept.has(key)) continue;
images.delete(key);
imageBytes -= entry.bytes;
}
}
async function acquire(
time,
mode,
signal,
{ box = null, keep = [], onFetched } = {},
) {
signal.throwIfAborted();
const key = cacheKey(time, mode, box);
const entry = images.get(key);
if (entry) {
images.delete(key);
images.set(key, entry);
return { texture: entry.image, decodeMs: 0, cached: true };
}
const result = await acquireWeatherImage(product, time, {
signal,
mode,
size: box ? detailSize : size,
bbox: box,
maxBytes: MAX_IMAGE_BYTES,
createCanvas,
fetchImpl,
decodeImage,
now,
onFetched,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
const previous = images.get(key);
if (previous) imageBytes -= previous.bytes;
images.delete(key);
const bytes = result.texture.width * result.texture.height * 4;
images.set(key, { image: result.texture, bytes });
imageBytes += bytes;
evict([key, ...keep]);
return { ...result, cached: false };
}
/** The detail box in the full-extent image's texture coordinates. */
function detailRect() {
const b = current.extent;
const w = detail.box;
const x = b.east - b.west;
const y = b.north - b.south;
return {
west: (w.west - b.west) / x,
south: (w.south - b.south) / y,
east: (w.east - b.west) / x,
north: (w.north - b.south) / y,
};
}
function syncDetail() {
if (detail && current) surface?.setDetail(detail.image, detailRect());
else surface?.setDetail(null);
}
function applyVisibility() {
return surface?.setShow(visible()) ?? false;
}
function cancelDetail() {
clearTimeout(detailJob?.timeout);
detailJob?.controller.abort();
detailJob = null;
}
function dropDetail() {
detail = null;
// The full-extent image covers the window in the same turn.
surface?.setDetail(null);
}
function installDetail(job, image) {
if (detailJob !== job) return;
clearTimeout(job.timeout);
detailJob = null;
detail = { box: job.box, key: job.key, image, stale: false };
syncDetail();
evict();
scene.requestRender();
}
function failDetail(job) {
if (detailJob !== job) return;
clearTimeout(job.timeout);
detailJob = null;
if (detail?.stale) dropDetail();
scene.requestRender();
}
/** Bring the detail to the shown frame and window. The previous detail image
* stays until the next is decoded unless the window itself moved, and over at
* most one newer frame (see commit). */
function refreshDetail() {
const key = wantedDetail();
if (!key) {
cancelDetail();
dropDetail();
return;
}
if (detail && boundsKey(detail.box) !== boundsKey(view)) dropDetail();
syncDetail();
if (detail?.key === key) {
detail.stale = false;
cancelDetail();
return;
}
// Another frame's detail: the next commit drops it unless replaced first.
if (detail) detail.stale = true;
if (!visible()) {
cancelDetail();
return;
}
if (detailJob?.key === key) return;
cancelDetail();
const job = { key, box: view, controller: new AbortController() };
detailJob = job;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
void acquire(current.time, current.infrared, job.controller.signal, {
box: view,
})
.then(({ texture }) => installDetail(job, texture))
.catch(() => failDetail(job));
}
/** Recompute the window from the camera's ground footprint; returns whether it changed. */
function computeView() {
const rectangle = detailed
? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid)
: null;
const degrees = cesium.Math.toDegrees;
const footprint = rectangle && {
west: degrees(rectangle.west),
south: degrees(rectangle.south),
east: degrees(rectangle.east),
north: degrees(rectangle.north),
};
const next = detailWindow(
footprint,
current.extent,
viewBounds === current.bounds ? view : null,
);
viewBounds = current.bounds;
if (next === view) return false;
view = next;
return true;
}
function watchCamera() {
if (offCamera || !detailed) return;
offCamera =
viewer.camera?.moveEnd?.addEventListener(() => {
if (current && computeView()) refreshDetail();
}) ?? null;
}
function cancelPrefetch() {
clearTimeout(prefetchJob?.timeout);
prefetchJob?.controller.abort();
prefetchJob = null;
prefetchedKey = null;
}
function watch(frame, signal) {
if (!signal) return;
frame.owned = true;
const abort = () => {
if (incoming === frame) cancelIncoming();
scene.requestRender();
};
signal.addEventListener('abort', abort, { once: true });
frame.offAbort = () => signal.removeEventListener('abort', abort);
}
function close(frame) {
frame.closed = true;
frame.controller.abort();
clearTimeout(frame.timeout);
frame.offAbort?.();
frame.surface?.destroy();
frame.surface = null;
}
function cancelIncoming() {
if (!incoming) return;
const frame = incoming;
incoming = null;
close(frame);
frame.resolve(false);
}
function fail(frame) {
if (incoming !== frame) return;
incoming = null;
close(frame);
lastError = 'Weather tiles unavailable · previous frame retained';
frame.resolve(false);
scene.requestRender();
onChange();
}
function commit(frame) {
if (incoming !== frame) return;
incoming = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
if (frame.surface) {
surface?.destroy();
surface = frame.surface;
frame.surface = null;
}
const { west, south, east, north } = frame.snapshot.bounds;
current = {
time: frame.time,
product: frame.product,
infrared: frame.infrared,
bounds: frame.bounds,
extent: { west, south, east, north },
key: frame.key,
mosaic: frame.mosaic,
loadMs: now() - frame.startedAt,
};
surface.setAlpha(alpha);
applyVisibility();
lastError = null;
evict();
// The full-extent image swaps first; the detail follows for the same time.
watchCamera();
if (viewBounds !== current.bounds) computeView();
// An older frame's detail stays over at most one newer frame.
if (detail?.stale && detail.key !== wantedDetail()) dropDetail();
refreshDetail();
frame.resolve(true);
scene.requestRender();
onChange();
}
function install(frame, image) {
if (frame.closed || incoming !== frame) return;
if (surface?.bounds === frame.bounds) {
// Cesium keeps drawing the previous texture until this one is uploaded.
surface.setImage(image);
commit(frame);
return;
}
// First frame or a new extent: stage an invisible surface and keep the
// previous one until the new geometry and texture are drawable.
const { west, south, east, north } = frame.snapshot.bounds;
const next = createShellSurface({
viewer,
cesium,
height,
rectangle: cesium.Rectangle.fromDegrees(west, south, east, north),
onSettled: () => commit(frame),
});
next.bounds = frame.bounds;
frame.surface = next;
next.setAlpha(0);
next.setImage(image);
}
return {
product,
rehome() {
if (suspended()) {
cancelPrefetch();
cancelIncoming();
cancelDetail();
}
const changed = applyVisibility();
if (current && !suspended()) {
computeView();
refreshDetail();
}
if (changed) scene.requestRender();
return changed;
},
cancelPrefetch,
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
let job;
try {
if (
hidden ||
incoming ||
suspended() ||
snapshot.product !== product ||
!snapshot.times.includes(time)
)
return false;
// Warm the next frame's detail window too while the window stays put.
const box =
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
const coarse = cacheKey(time, mode);
const key = box ? cacheKey(time, mode, box) : coarse;
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
prefetchJob = job;
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
// Both images at once: one after the other would outlast playback's step.
await Promise.all([
acquire(time, mode, signal, { keep: box ? [key] : [] }),
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
]);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
return true;
} catch {
job?.controller.abort();
return false;
} finally {
clearTimeout(job?.timeout);
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(
snapshot,
time,
{ signal, infrared: mode = 'filtered' } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
const bounds = boundsKey(snapshot.bounds);
const same = (frame) =>
frame?.time === time &&
frame.product === snapshot.product &&
frame.infrared === mode &&
frame.bounds === bounds;
// A request for the frame a host switch is restaging joins that work.
if (same(incoming) && !incoming.owned && !suspended()) {
watch(incoming, signal);
return incoming.result;
}
cancelIncoming();
applyVisibility();
if (suspended()) return false;
if (same(current)) return true;
lastError = null;
const frame = {
snapshot,
time,
infrared: mode,
bounds,
product: snapshot.product,
key: cacheKey(time, mode),
mosaic: { fetched: false, decodeMs: null, cached: false },
controller: new AbortController(),
startedAt: now(),
surface: null,
owned: false,
closed: false,
resolve: null,
};
incoming = frame;
frame.result = new Promise((resolve) => {
frame.resolve = resolve;
});
watch(frame, signal);
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
void acquire(time, mode, frame.controller.signal, {
onFetched: () => {
frame.mosaic.fetched = true;
},
})
.then(({ texture, decodeMs, cached }) => {
frame.mosaic.decodeMs = decodeMs;
frame.mosaic.cached = cached;
install(frame, texture);
})
.catch(() => fail(frame));
scene.requestRender();
onChange();
return frame.result;
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) {
cancelPrefetch();
cancelIncoming();
cancelDetail();
}
applyVisibility();
if (!hidden) refreshDetail();
scene.requestRender();
},
setAlpha(value) {
alpha = value;
surface?.setAlpha(alpha);
scene.requestRender();
},
clear() {
cancelPrefetch();
cancelIncoming();
cancelDetail();
offCamera?.();
offCamera = null;
dropDetail();
surface?.destroy();
surface = null;
current = null;
view = null;
viewBounds = null;
hidden = false;
lastError = null;
images.clear();
imageBytes = 0;
scene.requestRender();
},
getDiagnostics() {
const drawn = surface?.getDiagnostics();
const applied = drawn?.detail.window;
return {
host: 'shell',
height,
imageSize: { ...size },
shell: drawn
? {
...drawn,
detail: {
bbox: view
? [view.west, view.south, view.east, view.north]
: null,
size: { ...detailSize },
// The wanted window is applied and its image drawn.
ready: Boolean(
detail &&
current &&
detail.key === wantedDetail() &&
drawn.ready &&
drawn.show &&
drawn.detail.uploaded &&
applied &&
sameEdges(applied, detailRect()),
),
enabled: view !== null,
},
}
: null,
cache: {
mosaics: images.size,
bytes: imageBytes,
prefetching: !!prefetchJob,
},
imageryCount: Number(!!surface) + Number(!!incoming?.surface),
mosaic: (incoming || current)?.mosaic,
infrared: (incoming || current)?.infrared ?? 'filtered',
loading: !!incoming,
time: hidden ? null : (current?.time ?? null),
hidden,
product: current?.product ?? null,
frameLoadMs: current?.loadMs ?? null,
error: imageryHostStatus(getHost()) || lastError,
};
},
};
}
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const WEATHER_PRODUCTS = Object.freeze([
'radar',
'clouds',
'clouds-regional',
'lightning',
]);
/** Only bounded, explicit observations may become imagery requests. */
export function validateWeatherSnapshot(value, product) {
if (!value || value.schemaVersion !== 1 || value.product !== product)
throw new Error('Malformed weather manifest');
if (value.unavailable) return value;
const { bounds, times } = value;
if (
!bounds ||
!['west', 'south', 'east', 'north'].every((key) =>
Number.isFinite(bounds[key]),
) ||
bounds.west < -180 ||
bounds.east > 180 ||
bounds.south < -90 ||
bounds.north > 90 ||
bounds.west >= bounds.east ||
bounds.south >= bounds.north ||
!Array.isArray(times) ||
times.length < 1 ||
times.length > 13 ||
times.some(
(time, i) =>
typeof time !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(time) ||
!Number.isFinite(Date.parse(time)) ||
new Date(time).toISOString() !== time ||
(i > 0 && time <= times[i - 1]),
) ||
value.latest !== times.at(-1) ||
value.tileSize !== 256 ||
value.maxLevel !== 6 ||
value.tilingScheme !== 'geographic'
)
throw new Error('Malformed weather manifest');
return value;
}
/** Largest whole-extent image per product; also the proxy default size. */
export const WEATHER_IMAGE_SIZES = Object.freeze({
radar: Object.freeze({ width: 4096, height: 2048 }),
'clouds-regional': Object.freeze({ width: 4096, height: 2048 }),
clouds: Object.freeze({ width: 2048, height: 1024 }),
lightning: Object.freeze({ width: 4096, height: 2048 }),
});
/** Largest detail-window image for every product; also the proxy default. */
export const WEATHER_DETAIL_SIZE = Object.freeze({ width: 4096, height: 2048 });
/** A whole-extent image, or with `bbox` ({ west, south, east, north } degrees)
* a detail window of the same product. */
export function weatherImageUrl(
product,
time,
{ width, height } = {},
bbox = null,
) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
let box = '';
if (bbox !== null) {
const edges = [bbox.west, bbox.south, bbox.east, bbox.north];
if (
!edges.every(Number.isFinite) ||
edges[0] >= edges[2] ||
edges[1] >= edges[3]
)
throw new Error('Invalid weather window');
box = `&bbox=${edges.join(',')}`;
}
const largest =
bbox === null ? WEATHER_IMAGE_SIZES[product] : WEATHER_DETAIL_SIZE;
let size = '';
if (width !== undefined || height !== undefined) {
if (
![1024, 2048, 4096].includes(width) ||
height !== width / 2 ||
width > largest.width
)
throw new Error('Invalid weather image size');
// The largest size is the proxy default: one frame has one URL.
if (width !== largest.width) size = `&size=${width}x${height}`;
}
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${box}${size}`;
}
export function weatherTileUrl(product, time, { size } = {}) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
if (size !== undefined && ![256, 512, 1024].includes(size))
throw new Error('Invalid weather tile size');
// Construct locally; never accept a manifest-provided host or template.
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
}
/** Acquisition is lazy and shares the application's existing source contract. */
export function createWeatherSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ product = 'radar', signal } = {}) {
if (!WEATHER_PRODUCTS.includes(product))
throw new Error('Unknown weather product');
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Weather request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/weather/manifest?product=${product}`,
{ signal: controller.signal, cache: 'no-store', redirect: 'error' },
);
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
return validateWeatherSnapshot(
await readResponseJsonCapped(response, 16_384, controller.signal),
product,
);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherSource,
validateWeatherSnapshot,
weatherImageUrl,
weatherTileUrl,
WEATHER_DETAIL_SIZE,
WEATHER_IMAGE_SIZES,
} from './source.js';
const time = '2026-09-16T02:00:00.000Z';
const snapshot = () => ({
schemaVersion: 1,
product: 'radar',
bounds: { west: -130, south: 20, east: -60, north: 55 },
times: [time],
latest: time,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
});
test('observed source accepts bounded exact times and refuses malformed or unsorted frames', () => {
assert.equal(validateWeatherSnapshot(snapshot(), 'radar').latest, time);
for (const bad of [
{ times: [time, time] },
{ product: 'other' },
{ times: Array(14).fill(time) },
{ latest: 'tomorrow' },
{ maxLevel: 20 },
{ bounds: { west: -999, south: 0, east: 1, north: 1 } },
])
assert.throws(
() => validateWeatherSnapshot({ ...snapshot(), ...bad }, 'radar'),
/Malformed/,
);
});
test('weather tile URLs are same origin and ignore upstream templates', async () => {
const calls = [];
const source = createWeatherSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({
...snapshot(),
tileTemplate: 'https://example.invalid/{z}',
});
},
});
const result = await source.getSnapshot({ product: 'radar' });
assert.match(
weatherTileUrl(result.product, result.latest),
/^\/api\/weather\/tile\?product=radar&time=2026/,
);
assert.equal(calls[0].options.redirect, 'error');
assert.equal(calls[0].url, '/api/weather/manifest?product=radar');
});
test('source aborts before acquisition and caps streamed manifest bytes', async () => {
let calls = 0;
const source = createWeatherSource({
fetchImpl: async () => {
calls++;
return new Response(' '.repeat(17000));
},
});
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls, 0);
await assert.rejects(source.getSnapshot(), /too large/);
});
test('weather tile URLs carry only supported optional pixel sizes', () => {
assert.equal(
new URL(
weatherTileUrl('radar', time),
'https://example.test',
).searchParams.has('size'),
false,
);
for (const size of [256, 512, 1024]) {
const url = new URL(
weatherTileUrl('lightning', time, { size }),
'https://example.test',
);
assert.equal(url.searchParams.get('size'), String(size));
assert.equal(url.searchParams.get('time'), time);
}
for (const size of [0, 257, 2048, '1024', null])
assert.throws(() => weatherTileUrl('radar', time, { size }), /tile size/);
});
test('whole-extent image URLs are same origin and omit the default largest size', () => {
for (const [product, width] of [
['radar', 4096],
['clouds-regional', 4096],
['clouds', 2048],
['lightning', 4096],
]) {
assert.deepEqual(WEATHER_IMAGE_SIZES[product], {
width,
height: width / 2,
});
assert.equal(
weatherImageUrl(product, time),
`/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`,
);
assert.equal(
weatherImageUrl(product, time, { width, height: width / 2 }),
weatherImageUrl(product, time),
);
assert.equal(
new URL(
weatherImageUrl(product, time, { width: 1024, height: 512 }),
'https://example.test',
).searchParams.get('size'),
'1024x512',
);
}
for (const size of [
{ width: 8192, height: 4096 },
{ width: 2048, height: 2048 },
{ width: 512, height: 256 },
{ width: '1024', height: 512 },
])
assert.throws(() => weatherImageUrl('lightning', time, size), /size/);
assert.throws(
() => weatherImageUrl('clouds', time, { width: 4096, height: 2048 }),
/size/,
);
assert.throws(() => weatherImageUrl('other', time), /frame/);
assert.throws(() => weatherImageUrl('radar', 'latest'), /frame/);
});
test('detail-window image URLs carry the bbox and omit the default detail size', () => {
const box = { west: -102, south: 34, east: -96, north: 37 };
assert.deepEqual(WEATHER_DETAIL_SIZE, { width: 4096, height: 2048 });
for (const product of ['radar', 'clouds-regional', 'lightning', 'clouds']) {
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=-102,34,-96,37`;
assert.equal(weatherImageUrl(product, time, {}, box), url);
assert.equal(
weatherImageUrl(product, time, { width: 4096, height: 2048 }, box),
url,
'every product has a 4096×2048 detail default',
);
assert.equal(
weatherImageUrl(product, time, { width: 2048, height: 1024 }, box),
`${url}&size=2048x1024`,
);
}
assert.equal(
weatherImageUrl('radar', time, {}, { ...box, west: -97.5, east: -91.5 }),
`/api/weather/image?product=radar&time=${encodeURIComponent(time)}&bbox=-97.5,34,-91.5,37`,
);
for (const bad of [
{ ...box, west: -96 },
{ ...box, north: 34 },
{ ...box, east: Number.NaN },
{ west: -102, south: 34, east: -96 },
])
assert.throws(() => weatherImageUrl('radar', time, {}, bad), /window/);
assert.throws(
() => weatherImageUrl('radar', time, { width: 8192, height: 4096 }, box),
/size/,
);
});
File diff suppressed because it is too large. Load diff
+171
View File
@@ -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;
}
+114
View File
@@ -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');
});
+277
View File
@@ -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,
}),
};
}
+399
View File
@@ -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/);
});
+452
View File
@@ -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;
}
+414
View File
@@ -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();
});
+167
View File
@@ -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,
};
}
+112
View File
@@ -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);
});
+89
View File
@@ -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;
}
+34
View File
@@ -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));
});
+55
View File
@@ -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 } },
};
}
+22
View File
@@ -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);
});
+113
View File
@@ -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,
};
},
};
}
+101
View File
@@ -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);
});
+801
View File
@@ -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
+105
View File
@@ -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);
}
},
};
}
+245
View File
@@ -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]);
}
});
+137
View File
@@ -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()];
}
+122
View File
@@ -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);
});