mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(weather): add globe wind, observed weather and cyclone context (#617)
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
180 files changed
+30645
-434
No files matched your search
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
|
||||
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
|
||||
createCctvProjectionOverlayEntry:
|
||||
parts.projection.createCctvProjectionOverlayEntry,
|
||||
getActiveVideoElement: parts.projection.getActiveVideoElement,
|
||||
setCctvCardPresentationOptions:
|
||||
parts.cards.setCctvCardPresentationOptions,
|
||||
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
|
||||
|
||||
@@ -83,6 +83,9 @@ export function createPresentation({
|
||||
mountHeightM: camera.mountHeightM,
|
||||
active: isActive,
|
||||
feedType: camera.feedType,
|
||||
isVideo:
|
||||
parts.model.isVideoFeedType(camera.feedType) &&
|
||||
record.projection?.mode !== 'image',
|
||||
sourceKind:
|
||||
health?.sourceKind ||
|
||||
camera.sourceKind ||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { attachCctvVideo } from './videoPlayback.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
|
||||
@@ -207,6 +208,7 @@ export function createProjection({
|
||||
// only re-uploads the plane texture when there is genuinely new content.
|
||||
canvasStamp: 1,
|
||||
lastSwappedCanvasStamp: 0,
|
||||
disposed: false,
|
||||
};
|
||||
|
||||
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
|
||||
@@ -214,16 +216,49 @@ export function createProjection({
|
||||
if (mode === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.autoplay = true;
|
||||
video.playsInline = true;
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.src = parts.frames.mediaUrlFor(record.camera);
|
||||
video.addEventListener('canplay', () => {
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
// Cesium sizes the video texture from the element's width/height
|
||||
// attributes at first upload. Set them from the real stream dimensions
|
||||
// and rebind on any resolution change (camera switch, adaptive source).
|
||||
const bindVideoTexture = () => {
|
||||
if (runtime.video !== video || !runtime.planeMaterial) return;
|
||||
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
|
||||
video.width = video.videoWidth;
|
||||
video.height = video.videoHeight;
|
||||
runtime.planeMaterial.image = runtime.canvas;
|
||||
runtime.planeMaterial.image = video;
|
||||
};
|
||||
video.addEventListener('loadedmetadata', bindVideoTexture);
|
||||
video.addEventListener('resize', bindVideoTexture);
|
||||
runtime.video = video;
|
||||
runtime.playback = attachCctvVideo(
|
||||
video,
|
||||
parts.frames.mediaUrlFor(record.camera),
|
||||
feedType,
|
||||
{
|
||||
onFailure: () => {
|
||||
if (runtime.disposed) return;
|
||||
runtime.video = null;
|
||||
runtime.mode = 'image';
|
||||
runtime.image = new Image();
|
||||
runtime.image.decoding = 'async';
|
||||
runtime.image.onload = () => {
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = true;
|
||||
runtime.imageStamp = Date.now();
|
||||
};
|
||||
runtime.image.onerror = () => {
|
||||
runtime.imageLoading = false;
|
||||
runtime.imageReady = false;
|
||||
};
|
||||
runtime.planeMaterial.image = runtime.canvas;
|
||||
parts.frames.refreshProjectionImage(record, true);
|
||||
parts.presentation.notifyListeners();
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const img = new Image();
|
||||
img.decoding = 'async';
|
||||
@@ -254,7 +289,7 @@ export function createProjection({
|
||||
const positions =
|
||||
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
|
||||
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
|
||||
image: mode === 'video' && runtime.video ? runtime.video : canvas,
|
||||
image: canvas,
|
||||
transparent: true,
|
||||
color: Cesium.Color.WHITE.withAlpha(0.95),
|
||||
});
|
||||
@@ -271,7 +306,8 @@ export function createProjection({
|
||||
|
||||
function ensureProjectionRuntime(record) {
|
||||
if (!record) return null;
|
||||
if (record.projection) return record.projection;
|
||||
if (record.projection && !record.projection.disposed)
|
||||
return record.projection;
|
||||
const runtime = createProjectionRuntime(record);
|
||||
record.projection = runtime;
|
||||
if (runtime) {
|
||||
@@ -287,7 +323,15 @@ export function createProjection({
|
||||
*/
|
||||
|
||||
function destroyProjectionRuntime(runtime) {
|
||||
if (!runtime) return;
|
||||
if (!runtime || runtime.disposed) return;
|
||||
runtime.disposed = true;
|
||||
runtime.playback?.dispose();
|
||||
runtime.playback = null;
|
||||
if (runtime.image) {
|
||||
runtime.image.onload = null;
|
||||
runtime.image.onerror = null;
|
||||
runtime.image.src = '';
|
||||
}
|
||||
if (runtime.video) {
|
||||
runtime.video.pause();
|
||||
runtime.video.removeAttribute('src');
|
||||
@@ -324,10 +368,7 @@ export function createProjection({
|
||||
const active = parts.selection.getActiveRecord();
|
||||
if (layerState._enabled && layerState._showProjection && active) {
|
||||
ensureProjectionRuntime(active);
|
||||
if (active.projection?.video) {
|
||||
active.projection.video.play().catch(() => {});
|
||||
}
|
||||
if (active.projection) {
|
||||
if (active.projection && !active.projection.video) {
|
||||
parts.frames.drawProjectionFrame(active);
|
||||
parts.frames.refreshProjectionTextures(active);
|
||||
}
|
||||
@@ -355,22 +396,34 @@ export function createProjection({
|
||||
* @param {string|null} activeId - ID of the currently active camera.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The active camera's decoded <video>, for a second surface (the panel card)
|
||||
* to paint from. Null when the active feed is a still or not yet attached.
|
||||
* @returns {HTMLVideoElement|null}
|
||||
*/
|
||||
function getActiveVideoElement() {
|
||||
if (!layerState._enabled) return null;
|
||||
return parts.selection.getActiveRecord()?.projection?.video || null;
|
||||
}
|
||||
|
||||
function pauseInactiveProjectionFeeds(activeId) {
|
||||
for (const record of layerState._records) {
|
||||
if (!record.projection?.video) continue;
|
||||
if (
|
||||
record.camera.id === activeId &&
|
||||
layerState._enabled &&
|
||||
layerState._showProjection
|
||||
) {
|
||||
if (record.camera.id === activeId && layerState._enabled) {
|
||||
record.projection.video.play().catch(() => {});
|
||||
} else {
|
||||
record.projection.video.pause();
|
||||
const runtime = record.projection;
|
||||
destroyProjectionRuntime(runtime);
|
||||
record.projection = null;
|
||||
layerState._projectionEntities = layerState._projectionEntities.filter(
|
||||
(entry) => entry !== runtime,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
createCctvProjectionOverlayEntry,
|
||||
getActiveVideoElement,
|
||||
updatePlanePlacement,
|
||||
clearProjectionOverlay,
|
||||
setPlaneVisible,
|
||||
|
||||
@@ -215,7 +215,12 @@ export function createRendering({
|
||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||
}
|
||||
|
||||
if (layerState._enabled && layerState._showProjection && isActive) {
|
||||
if (
|
||||
layerState._enabled &&
|
||||
isActive &&
|
||||
(layerState._showProjection ||
|
||||
parts.model.isVideoFeedType(record.camera.feedType))
|
||||
) {
|
||||
parts.projection.ensureProjectionRuntime(record);
|
||||
}
|
||||
// One live plane in the world at a time (§2c): only the active camera's
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/** One decoder per active camera; both surfaces consume this video element. */
|
||||
export function attachCctvVideo(
|
||||
video,
|
||||
url,
|
||||
feedType,
|
||||
{
|
||||
loadHls = () => import('hls.js'),
|
||||
onFailure = () => {},
|
||||
fetchImpl = globalThis.fetch,
|
||||
} = {},
|
||||
) {
|
||||
// Preserve replay for existing finite video feeds; live HLS must not loop.
|
||||
video.loop = feedType !== 'hls';
|
||||
let disposed = false;
|
||||
let hls = null;
|
||||
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
|
||||
const mediaUrl =
|
||||
feedType === 'hls'
|
||||
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||
: url;
|
||||
let timer = null;
|
||||
let startup = null;
|
||||
let governor = null;
|
||||
let retries = 0;
|
||||
const release = () => {
|
||||
if (feedType === 'hls') {
|
||||
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
};
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(startup);
|
||||
clearInterval(governor);
|
||||
hls?.destroy();
|
||||
hls = null;
|
||||
video.removeEventListener('canplay', play);
|
||||
video.removeEventListener('error', fail);
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
release();
|
||||
};
|
||||
const fail = () => {
|
||||
if (disposed) return;
|
||||
dispose();
|
||||
onFailure();
|
||||
};
|
||||
const play = () => {
|
||||
if (disposed) return;
|
||||
clearTimeout(startup);
|
||||
video.play().catch(() => {});
|
||||
};
|
||||
video.addEventListener('canplay', play);
|
||||
video.addEventListener('error', fail);
|
||||
startup = setTimeout(fail, 30000);
|
||||
const ready = (async () => {
|
||||
if (feedType !== 'hls') {
|
||||
video.src = mediaUrl;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { default: Hls } = await loadHls();
|
||||
if (disposed) return;
|
||||
if (!Hls.isSupported()) {
|
||||
if (video.canPlayType('application/vnd.apple.mpegurl'))
|
||||
video.src = mediaUrl;
|
||||
else fail();
|
||||
return;
|
||||
}
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
maxBufferLength: 24,
|
||||
maxMaxBufferLength: 30,
|
||||
backBufferLength: 0,
|
||||
maxBufferSize: 16 * 1024 * 1024,
|
||||
liveSyncDurationCount: 3,
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (disposed || !data?.fatal) return;
|
||||
if (++retries > 2) {
|
||||
fail();
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (disposed) return;
|
||||
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
|
||||
else hls.loadSource(mediaUrl);
|
||||
}, 2000);
|
||||
});
|
||||
governor = setInterval(() => {
|
||||
if (disposed || !video.buffered?.length) return;
|
||||
const ahead =
|
||||
video.buffered.end(video.buffered.length - 1) - video.currentTime;
|
||||
// Some agency encoders publish less media time than wall time. Bound
|
||||
// correction rather than repeatedly draining the live buffer at 1x.
|
||||
video.playbackRate =
|
||||
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
|
||||
}, 1000);
|
||||
hls.attachMedia(video);
|
||||
hls.loadSource(mediaUrl);
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
})();
|
||||
return { dispose, ready };
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { attachCctvVideo } from './videoPlayback.js';
|
||||
function video() {
|
||||
const v = new EventTarget();
|
||||
Object.assign(v, {
|
||||
src: '',
|
||||
pause() {},
|
||||
load() {},
|
||||
removeAttribute() {},
|
||||
play: () => Promise.resolve(),
|
||||
});
|
||||
return v;
|
||||
}
|
||||
test('switch during lazy import never starts the stale decoder', async () => {
|
||||
let resolve;
|
||||
let constructed = 0;
|
||||
class Hls {
|
||||
constructor() {
|
||||
constructed++;
|
||||
}
|
||||
static isSupported() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const source = video();
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||
loadHls: () =>
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
});
|
||||
playback.dispose();
|
||||
resolve({ default: Hls });
|
||||
await playback.ready;
|
||||
assert.equal(constructed, 0);
|
||||
assert.equal(source.src, '');
|
||||
});
|
||||
test('missing decoder uses honest failure fallback once', async () => {
|
||||
let failures = 0;
|
||||
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
|
||||
loadHls: async () => {
|
||||
throw new Error('unavailable');
|
||||
},
|
||||
onFailure: () => failures++,
|
||||
});
|
||||
await playback.ready;
|
||||
playback.dispose();
|
||||
assert.equal(failures, 1);
|
||||
});
|
||||
|
||||
test('native HLS releases its client lease without response-header access', async () => {
|
||||
const source = video();
|
||||
source.canPlayType = () => 'probably';
|
||||
const releases = [];
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||
fetchImpl: async (url, init) => {
|
||||
releases.push({ url, init });
|
||||
},
|
||||
});
|
||||
await playback.ready;
|
||||
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
|
||||
const requested = source.src;
|
||||
playback.dispose();
|
||||
playback.dispose();
|
||||
assert.equal(releases.length, 1);
|
||||
assert.equal(releases[0].url, requested);
|
||||
assert.equal(releases[0].init.method, 'DELETE');
|
||||
});
|
||||
|
||||
test('finite video feeds retain looping while live HLS does not loop', async () => {
|
||||
for (const feedType of ['mp4', 'webm', 'hls']) {
|
||||
const source = video();
|
||||
source.loop = feedType === 'hls';
|
||||
source.canPlayType = () => 'probably';
|
||||
let imports = 0;
|
||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
|
||||
loadHls: async () => {
|
||||
imports++;
|
||||
return { default: { isSupported: () => false } };
|
||||
},
|
||||
fetchImpl: async () => {},
|
||||
});
|
||||
try {
|
||||
await playback.ready;
|
||||
assert.equal(source.loop, feedType !== 'hls');
|
||||
assert.equal(imports, feedType === 'hls' ? 1 : 0);
|
||||
} finally {
|
||||
playback.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
import {
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
} from '../../data/pickRegistry.js';
|
||||
import {
|
||||
createCycloneRendering,
|
||||
coherentCycloneGeometry,
|
||||
} from './rendering.js';
|
||||
import {
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
const CLASSIFICATION_NAMES = Object.freeze({
|
||||
PTC: 'Potential tropical cyclone',
|
||||
HU: 'Hurricane',
|
||||
TS: 'Tropical storm',
|
||||
TD: 'Tropical depression',
|
||||
SS: 'Subtropical storm',
|
||||
SD: 'Subtropical depression',
|
||||
});
|
||||
const classificationName = (code) =>
|
||||
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
|
||||
const number = (value, unit) =>
|
||||
value === null ? 'Unavailable' : `${value} ${unit}`;
|
||||
|
||||
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
|
||||
export function createCyclonesLayer({
|
||||
feed,
|
||||
hitTestOverlay = hitTestWorldOverlay,
|
||||
overlayHost,
|
||||
cesium = Cesium,
|
||||
createRendering = createCycloneRendering,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Cyclones require a snapshot source');
|
||||
let viewer = null,
|
||||
rendering = null,
|
||||
snapshot = null,
|
||||
request = null,
|
||||
listener = null,
|
||||
selectedId = null,
|
||||
selectionIntent = 'auto',
|
||||
navigationGeneration = 0,
|
||||
clickHandler = null,
|
||||
removeClickCapture = null;
|
||||
let enabled = false,
|
||||
loading = false,
|
||||
error = null,
|
||||
destroyed = false,
|
||||
runNavigation = null;
|
||||
const notify = () => listener?.();
|
||||
const selected = () =>
|
||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||
function select(id) {
|
||||
if (selectedId !== id) ++navigationGeneration;
|
||||
selectedId = id;
|
||||
rendering?.setSelection(id);
|
||||
}
|
||||
// Photorealistic 3D Tiles pick as tileset content without an entity id;
|
||||
// that is empty map, the same as no pick at all on the globe.
|
||||
const isSurfacePick = (picked) =>
|
||||
!picked ||
|
||||
(picked.id === undefined &&
|
||||
(picked.content !== undefined ||
|
||||
(typeof cesium.Cesium3DTileset === 'function' &&
|
||||
picked.primitive instanceof cesium.Cesium3DTileset)));
|
||||
function installSelection() {
|
||||
if (
|
||||
clickHandler ||
|
||||
!viewer?.scene?.canvas ||
|
||||
typeof cesium.ScreenSpaceEventHandler !== 'function'
|
||||
)
|
||||
return;
|
||||
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
clickHandler = owner;
|
||||
// Snapshot before sibling bubble listeners can rebuild the overlay hit
|
||||
// rectangles. A vessel selection does that synchronously in the same click.
|
||||
const canvas = viewer.scene.canvas;
|
||||
let capturedHit = null;
|
||||
const overlayHit = (x, y) => {
|
||||
const hit = hitTestOverlay(x, y);
|
||||
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
|
||||
};
|
||||
const capture = (event) => {
|
||||
capturedHit = null;
|
||||
const point = event.changedTouches?.[0] || event;
|
||||
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
|
||||
return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = point.clientX - rect.left,
|
||||
y = point.clientY - rect.top;
|
||||
capturedHit = { x, y, ...overlayHit(x, y) };
|
||||
};
|
||||
const resetCapture = () => {
|
||||
capturedHit = null;
|
||||
};
|
||||
const resetEvents = [
|
||||
'pointerdown',
|
||||
'mousedown',
|
||||
'touchstart',
|
||||
'pointercancel',
|
||||
'touchcancel',
|
||||
];
|
||||
for (const type of resetEvents)
|
||||
canvas.addEventListener?.(type, resetCapture, { capture: true });
|
||||
const events = ['pointerup', 'mouseup', 'touchend'];
|
||||
for (const type of events)
|
||||
canvas.addEventListener?.(type, capture, { capture: true });
|
||||
removeClickCapture = () => {
|
||||
for (const type of events)
|
||||
canvas.removeEventListener?.(type, capture, { capture: true });
|
||||
for (const type of resetEvents)
|
||||
canvas.removeEventListener?.(type, resetCapture, { capture: true });
|
||||
capturedHit = null;
|
||||
};
|
||||
owner.setInputAction((click) => {
|
||||
const nativeHit = capturedHit;
|
||||
capturedHit = null;
|
||||
// Ambient selection yields to draw tools and Director; it never claims
|
||||
// the pointer, camera, or tracking state.
|
||||
if (
|
||||
!enabled ||
|
||||
destroyed ||
|
||||
clickHandler !== owner ||
|
||||
!isPointerFree() ||
|
||||
!click?.position
|
||||
)
|
||||
return;
|
||||
// AIS cards paint above the globe on a pointer-events:none canvas. The
|
||||
// vessel handler resolves this same topmost hit before cyclone geometry.
|
||||
const captureMatches =
|
||||
nativeHit &&
|
||||
Math.abs(nativeHit.x - click.position.x) < 1 &&
|
||||
Math.abs(nativeHit.y - click.position.y) < 1;
|
||||
const hit = captureMatches
|
||||
? nativeHit
|
||||
: overlayHit(click.position.x, click.position.y);
|
||||
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
|
||||
// Storm cards and lead-hour labels paint on the same canvas; a click on
|
||||
// one selects its storm. An id from a superseded advisory changes nothing.
|
||||
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
|
||||
const id = cycloneStormIdFromEntryId(hit.entryId);
|
||||
if (id) layer.setParams({ stormId: id });
|
||||
return;
|
||||
}
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id = rendering?.pickStorm(picked);
|
||||
if (id) layer.setParams({ stormId: id });
|
||||
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
|
||||
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
function removeSelection() {
|
||||
const owner = clickHandler;
|
||||
clickHandler = null;
|
||||
removeClickCapture?.();
|
||||
removeClickCapture = null;
|
||||
if (owner && !owner.isDestroyed?.()) owner.destroy();
|
||||
}
|
||||
const layer = {
|
||||
id: 'weather-cyclones',
|
||||
name: 'Cyclone advisories',
|
||||
icon: '◉',
|
||||
source: 'NOAA NHC / CPHC',
|
||||
updateInterval: 300_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({ viewer, cesium, overlayHost });
|
||||
},
|
||||
attachShellServices(services) {
|
||||
runNavigation =
|
||||
typeof services?.runNavigation === 'function'
|
||||
? services.runNavigation
|
||||
: null;
|
||||
notify();
|
||||
},
|
||||
enable() {
|
||||
if (!destroyed && !enabled) {
|
||||
enabled = true;
|
||||
registerPickOwner(
|
||||
'weather-cyclones',
|
||||
(id) => enabled && rendering?.ownsPickId?.(id) === true,
|
||||
);
|
||||
installSelection();
|
||||
}
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
unregisterPickOwner('weather-cyclones');
|
||||
removeSelection();
|
||||
request?.abort();
|
||||
request = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
selectionIntent = 'auto';
|
||||
++navigationGeneration;
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
if (!enabled || destroyed) return false;
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
if (signal?.aborted) controller.abort(signal.reason);
|
||||
request = controller;
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const next = await feed.getSnapshot({ signal: controller.signal });
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
if (next.unavailable) {
|
||||
// An expired advisory must not remain presented as current hazard context.
|
||||
rendering.clear();
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
error = next.reason || 'Cyclone advisories unavailable';
|
||||
return true;
|
||||
}
|
||||
const applied = await rendering.setSnapshot(next, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
!applied ||
|
||||
!enabled ||
|
||||
controller.signal.aborted ||
|
||||
request !== controller
|
||||
)
|
||||
return false;
|
||||
snapshot = next;
|
||||
error = null;
|
||||
if (!snapshot.storms.some((storm) => storm.id === selectedId))
|
||||
select(
|
||||
selectionIntent === 'cleared'
|
||||
? null
|
||||
: snapshot.storms[0]?.id || null,
|
||||
);
|
||||
else rendering.setSelection(selectedId);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Cyclone advisories unavailable';
|
||||
// Failed acquisition has no bounded last-good age guarantee at this layer.
|
||||
rendering?.clear();
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
if (!enabled || destroyed) return;
|
||||
if (params.clear === true || params.stormId === null) {
|
||||
selectionIntent = 'cleared';
|
||||
select(null);
|
||||
notify();
|
||||
} else if (
|
||||
typeof params.stormId === 'string' &&
|
||||
snapshot?.storms.some((storm) => storm.id === params.stormId)
|
||||
) {
|
||||
selectionIntent = 'user';
|
||||
select(params.stormId);
|
||||
notify();
|
||||
}
|
||||
const storm = selected();
|
||||
if (params.focus === true && storm && runNavigation) {
|
||||
const sphere = rendering.getFocusSphere(storm.id);
|
||||
if (sphere) {
|
||||
const generation = ++navigationGeneration;
|
||||
runNavigation(() => {
|
||||
if (
|
||||
!enabled ||
|
||||
destroyed ||
|
||||
generation !== navigationGeneration ||
|
||||
selectedId !== storm.id
|
||||
)
|
||||
return;
|
||||
return viewer.camera.flyToBoundingSphere(sphere, {
|
||||
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
?.matches
|
||||
? 0
|
||||
: 1.4,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
if (params.advisory === true && storm?.advisoryUrl)
|
||||
openLink(storm.advisoryUrl);
|
||||
},
|
||||
getRowControls() {
|
||||
const storm = selected();
|
||||
const empty =
|
||||
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
|
||||
const geometry =
|
||||
storm &&
|
||||
(coherentCycloneGeometry(storm)
|
||||
? 'Track and cone match this advisory'
|
||||
: storm.geometryStatus === 'pending'
|
||||
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
|
||||
: 'Track/cone unavailable');
|
||||
const status =
|
||||
error ||
|
||||
(snapshot?.stale
|
||||
? 'Cached advisory · stale source'
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: storm && !coherentCycloneGeometry(storm)
|
||||
? geometry
|
||||
: null);
|
||||
const detail = storm
|
||||
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
|
||||
: empty
|
||||
? 'No active NHC/CPHC systems'
|
||||
: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
|
||||
: loading
|
||||
? 'Loading advisories…'
|
||||
: 'Advisories unavailable';
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: 'Cyclones · NHC / CPHC',
|
||||
coverage: 'Atlantic · E/C Pacific',
|
||||
compact: snapshot?.storms.length
|
||||
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
|
||||
: detail,
|
||||
actions: storm?.advisoryUrl
|
||||
? [
|
||||
{
|
||||
id: 'advisory',
|
||||
label: 'Official advisory ↗',
|
||||
href: storm.advisoryUrl,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
settings: [],
|
||||
lines: storm
|
||||
? [
|
||||
{
|
||||
id: 'position',
|
||||
text: `Position as of ${utc(storm.positionAt)}`,
|
||||
muted: true,
|
||||
},
|
||||
{
|
||||
id: 'intensity',
|
||||
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
|
||||
},
|
||||
{ id: 'geometry', text: geometry, muted: true },
|
||||
]
|
||||
: [],
|
||||
detail,
|
||||
status,
|
||||
units: 'kt',
|
||||
},
|
||||
list: {
|
||||
ariaLabel: 'Active NHC and CPHC cyclone advisories',
|
||||
items: (snapshot?.storms || []).map((item, index) => ({
|
||||
id: item.id,
|
||||
ordinal: index + 1,
|
||||
lead: item.basin,
|
||||
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
|
||||
active: item.id === selectedId,
|
||||
params: { stormId: item.id, focus: true },
|
||||
})),
|
||||
},
|
||||
chips: [],
|
||||
legend: storm
|
||||
? [
|
||||
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
|
||||
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
|
||||
]
|
||||
: [],
|
||||
info: storm
|
||||
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
|
||||
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
|
||||
infoTitle:
|
||||
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
|
||||
};
|
||||
return controls;
|
||||
},
|
||||
setRowControlsListener(value) {
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
getStats() {
|
||||
const storm = selected();
|
||||
return {
|
||||
count: snapshot?.storms.length || 0,
|
||||
lastUpdate: storm
|
||||
? Date.parse(storm.issuedAt)
|
||||
: snapshot?.fetchedAt || null,
|
||||
loading,
|
||||
error,
|
||||
stale: Boolean(snapshot?.stale),
|
||||
source: 'NOAA NHC / CPHC',
|
||||
advisoryAt: storm?.issuedAt || null,
|
||||
empty: Boolean(
|
||||
snapshot && !snapshot.unavailable && !snapshot.storms.length,
|
||||
),
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
...rendering?.getDiagnostics(),
|
||||
enabled,
|
||||
loading,
|
||||
requestPending: !!request,
|
||||
selectionActive: clickHandler !== null,
|
||||
selectedId,
|
||||
selectionIntent,
|
||||
timerActive: false,
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
layer.disable();
|
||||
destroyed = true;
|
||||
rendering?.destroy();
|
||||
rendering = null;
|
||||
viewer = null;
|
||||
listener = null;
|
||||
runNavigation = null;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCyclonesLayer } from './index.js';
|
||||
import {
|
||||
claimPointer,
|
||||
releasePointer,
|
||||
isPointerFree,
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = (id = 'ep152026') => ({
|
||||
id,
|
||||
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
|
||||
classification: 'PTC',
|
||||
basin: 'EP',
|
||||
position: { longitude: -125.8, latitude: 15.5 },
|
||||
positionAt: time,
|
||||
issuedAt: time,
|
||||
advisoryNumber: '10',
|
||||
windKt: 25,
|
||||
pressureHpa: 1006,
|
||||
geometryStatus: 'pending',
|
||||
geometryAdvisoryNumber: null,
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
});
|
||||
const snapshot = (storms = [storm()]) => ({
|
||||
schemaVersion: 1,
|
||||
storms,
|
||||
unavailable: false,
|
||||
stale: false,
|
||||
fetchedAt: Date.parse(time),
|
||||
coverage: 'Atlantic and eastern/central North Pacific',
|
||||
reason: null,
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
|
||||
const handlers = [],
|
||||
pickedEntity = {},
|
||||
foreignEntity = {};
|
||||
let picked = { id: pickedEntity },
|
||||
overlayHit = null,
|
||||
overlayTests = 0,
|
||||
picks = 0,
|
||||
notices = 0;
|
||||
const rendering = {
|
||||
setSnapshot: async () => true,
|
||||
setSelection() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
|
||||
ownsPickId: (id) => id === 'cyclone:ep162026:center',
|
||||
getDiagnostics: () => ({}),
|
||||
};
|
||||
const cesium = {
|
||||
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
|
||||
ScreenSpaceEventHandler: class {
|
||||
constructor(canvas) {
|
||||
assert.ok(canvas);
|
||||
handlers.push(this);
|
||||
this.destroyed = false;
|
||||
}
|
||||
setInputAction(callback, type) {
|
||||
assert.equal(type, 'left');
|
||||
this.click = callback;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
},
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||
cesium,
|
||||
createRendering: () => rendering,
|
||||
hitTestOverlay: () => {
|
||||
overlayTests++;
|
||||
return overlayHit;
|
||||
},
|
||||
});
|
||||
const canvas = new EventTarget();
|
||||
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
|
||||
const viewer = {
|
||||
scene: {
|
||||
canvas,
|
||||
pick() {
|
||||
picks++;
|
||||
return picked;
|
||||
},
|
||||
},
|
||||
trackedEntity: null,
|
||||
camera: {
|
||||
flyToBoundingSphere() {
|
||||
assert.fail('Click must not move the camera');
|
||||
},
|
||||
},
|
||||
};
|
||||
layer.init(viewer);
|
||||
layer.setRowControlsListener(() => notices++);
|
||||
assert.equal(handlers.length, 0, 'disabled module installs no handler');
|
||||
layer.enable();
|
||||
layer.enable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
true,
|
||||
'flight tracking recognizes the cyclone pick and leaves the camera alone',
|
||||
);
|
||||
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
|
||||
false,
|
||||
'registration recognizes exact current IDs, not a prefix',
|
||||
);
|
||||
assert.equal(handlers.length, 1, 'enable is idempotent');
|
||||
await layer.update();
|
||||
const click = { position: { x: 10, y: 20 } };
|
||||
const owner = claimPointer('director');
|
||||
assert.ok(owner);
|
||||
try {
|
||||
handlers[0].click(click);
|
||||
assert.equal(picks, 0, 'claimed pointer is checked before picking');
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
} finally {
|
||||
releasePointer(owner);
|
||||
}
|
||||
picked = { id: foreignEntity };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
picked = { primitive: {} };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
|
||||
picked = { id: pickedEntity };
|
||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||
const beforeCard = picks;
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
picks,
|
||||
beforeCard,
|
||||
'foreground AIS card wins before scene picking',
|
||||
);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
// A native capture runs before an earlier sibling handler clears hit rects.
|
||||
const up = new Event('pointerup');
|
||||
Object.assign(up, { clientX: 110, clientY: 220 });
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
'ep152026',
|
||||
'captured AIS hit survives synchronous sibling overlay rebuild',
|
||||
);
|
||||
// A captured background hit remains background even if a sibling adds a card.
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
// A new gesture clears an unconsumed release snapshot.
|
||||
canvas.dispatchEvent(up);
|
||||
canvas.dispatchEvent(new Event('pointerdown'));
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = null;
|
||||
handlers[0].click({ position: { x: 15, y: 25 } });
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
'ep162026',
|
||||
'a different click cannot reuse the captured card',
|
||||
);
|
||||
layer.setParams({ stormId: 'ep152026' });
|
||||
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
|
||||
const before = notices;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
assert.match(layer.getRowControls().summary.detail, /Another system/);
|
||||
assert.equal(layer.getRowControls().list.items[1].active, true);
|
||||
assert.ok(notices > before);
|
||||
assert.equal(viewer.trackedEntity, null);
|
||||
picked = undefined;
|
||||
overlayHit = { sourceId: 'ais-live-vessels' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
picked = { id: pickedEntity };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
picked = { id: 'flight:abc', primitive: {} };
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
'ep162026',
|
||||
"another layer's pick leaves the selection alone",
|
||||
);
|
||||
picked = { content: {}, primitive: {} };
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
layer.getDiagnostics().selectedId,
|
||||
null,
|
||||
'3D Tiles surface content is empty map',
|
||||
);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
await layer.update();
|
||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||
assert.equal(
|
||||
isPointerFree(),
|
||||
true,
|
||||
'ambient selection never claims the pointer',
|
||||
);
|
||||
layer.disable();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
false,
|
||||
);
|
||||
assert.equal(handlers[0].destroyed, true);
|
||||
assert.equal(layer.getDiagnostics().selectionActive, false);
|
||||
const afterDisable = picks;
|
||||
const afterDisableOverlay = overlayTests;
|
||||
canvas.dispatchEvent(up);
|
||||
assert.equal(
|
||||
overlayTests,
|
||||
afterDisableOverlay,
|
||||
'disable removes native capture listeners',
|
||||
);
|
||||
handlers[0].click(click);
|
||||
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
|
||||
handlers[0].click(click);
|
||||
assert.equal(
|
||||
picks,
|
||||
afterDisable,
|
||||
'superseded handler remains inert after re-enable',
|
||||
);
|
||||
assert.equal(handlers.length, 2);
|
||||
layer.destroy();
|
||||
assert.equal(
|
||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||
false,
|
||||
);
|
||||
assert.equal(handlers[1].destroyed, true);
|
||||
});
|
||||
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
|
||||
const handlers = [];
|
||||
let picks = 0,
|
||||
overlayHit = null,
|
||||
hostArgument;
|
||||
const layer = createCyclonesLayer({
|
||||
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||
cesium: {
|
||||
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
|
||||
ScreenSpaceEventHandler: class {
|
||||
constructor() {
|
||||
handlers.push(this);
|
||||
}
|
||||
setInputAction(callback) {
|
||||
this.click = callback;
|
||||
}
|
||||
destroy() {}
|
||||
},
|
||||
},
|
||||
overlayHost: 'host',
|
||||
createRendering: ({ overlayHost }) => {
|
||||
hostArgument = overlayHost;
|
||||
return {
|
||||
setSnapshot: async () => true,
|
||||
setSelection() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
pickStorm: () => null,
|
||||
ownsPickId: () => false,
|
||||
getDiagnostics: () => ({}),
|
||||
};
|
||||
},
|
||||
hitTestOverlay: () => overlayHit,
|
||||
});
|
||||
const canvas = new EventTarget();
|
||||
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
|
||||
layer.init({
|
||||
scene: {
|
||||
canvas,
|
||||
pick() {
|
||||
picks++;
|
||||
return { content: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
const click = { position: { x: 10, y: 20 } };
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
// A card from a superseded advisory is not empty map.
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
// The native release snapshot carries the card identity too.
|
||||
const up = new Event('pointerup');
|
||||
Object.assign(up, { clientX: 10, clientY: 20 });
|
||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
||||
canvas.dispatchEvent(up);
|
||||
overlayHit = null;
|
||||
handlers[0].click(click);
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||
assert.equal(picks, 0, 'card clicks never fall through to the scene');
|
||||
handlers[0].click(click);
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
|
||||
layer.destroy();
|
||||
});
|
||||
function harness(
|
||||
feed = { getSnapshot: async () => snapshot() },
|
||||
{ reducedMotion = false } = {},
|
||||
) {
|
||||
const applied = [],
|
||||
navigation = [],
|
||||
opened = [];
|
||||
let cleared = 0,
|
||||
destroyed = 0,
|
||||
selection = null;
|
||||
const rendering = {
|
||||
async setSnapshot(value) {
|
||||
applied.push(value);
|
||||
return true;
|
||||
},
|
||||
setSelection(id) {
|
||||
selection = id;
|
||||
},
|
||||
getFocusSphere: () => ({ radius: 500000 }),
|
||||
clear() {
|
||||
cleared++;
|
||||
},
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
getDiagnostics: () => ({
|
||||
entities: applied.at(-1)?.storms.length || 0,
|
||||
timerActive: false,
|
||||
}),
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
openLink: (url) => opened.push(url),
|
||||
});
|
||||
const viewer = {
|
||||
camera: {
|
||||
flyToBoundingSphere: (sphere, options) =>
|
||||
navigation.push({ sphere, options }),
|
||||
},
|
||||
};
|
||||
layer.init(viewer);
|
||||
layer.attachShellServices({
|
||||
runNavigation: (fn) => {
|
||||
navigation.push('claimed');
|
||||
return fn();
|
||||
},
|
||||
});
|
||||
return {
|
||||
layer,
|
||||
applied,
|
||||
navigation,
|
||||
opened,
|
||||
get cleared() {
|
||||
return cleared;
|
||||
},
|
||||
get destroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
get selection() {
|
||||
return selection;
|
||||
},
|
||||
};
|
||||
}
|
||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
|
||||
next = snapshot([storm('ep162026')]);
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep162026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
|
||||
next = snapshot([storm(), storm('ep162026')]);
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep162026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
|
||||
assert.equal(h.navigation.length, 0);
|
||||
next = snapshot([storm()]);
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
|
||||
for (const clear of [{ stormId: null }, { clear: true }]) {
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
h.layer.setParams(clear);
|
||||
assert.equal(h.selection, null);
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, null);
|
||||
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
|
||||
}
|
||||
h.layer.disable();
|
||||
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
|
||||
const staged = deferred();
|
||||
const staging = deferred();
|
||||
let calls = 0;
|
||||
const layer = createCyclonesLayer({
|
||||
feed: { getSnapshot: async () => snapshot() },
|
||||
createRendering: () => ({
|
||||
setSnapshot() {
|
||||
if (++calls === 1) return true;
|
||||
staging.resolve();
|
||||
return staged.promise;
|
||||
},
|
||||
setSelection() {},
|
||||
getDiagnostics: () => ({}),
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
layer.init({});
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||
const pending = layer.update();
|
||||
await staging.promise;
|
||||
layer.setParams({ clear: true });
|
||||
staged.resolve(true);
|
||||
await pending;
|
||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
|
||||
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
const callbacks = [];
|
||||
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026', focus: true });
|
||||
if (change === 'select') {
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
} else if (change === 'clear') h.layer.setParams({ stormId: null });
|
||||
else if (change === 'fallback') {
|
||||
next = snapshot([storm('ep162026')]);
|
||||
await h.layer.update();
|
||||
} else h.layer[change]();
|
||||
callbacks.shift()();
|
||||
assert.equal(h.navigation.length, 0, change);
|
||||
h.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
|
||||
const h = harness();
|
||||
const callbacks = [];
|
||||
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026', focus: true });
|
||||
h.layer.setParams({ stormId: 'ep152026', focus: true });
|
||||
await h.layer.update();
|
||||
callbacks[0]();
|
||||
assert.equal(h.navigation.length, 0);
|
||||
callbacks[1]();
|
||||
assert.equal(h.navigation.length, 1);
|
||||
assert.equal(h.navigation[0].options.duration, 1.4);
|
||||
h.layer.destroy();
|
||||
});
|
||||
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
|
||||
for (const [code, label] of [
|
||||
['PTC', 'Potential tropical cyclone'],
|
||||
['HU', 'Hurricane'],
|
||||
['TS', 'Tropical storm'],
|
||||
['TD', 'Tropical depression'],
|
||||
['SS', 'Subtropical storm'],
|
||||
['SD', 'Subtropical depression'],
|
||||
['EX', 'EX'],
|
||||
['UNKNOWN', 'UNKNOWN'],
|
||||
]) {
|
||||
const record = { ...storm(), classification: code };
|
||||
const h = harness({ getSnapshot: async () => snapshot([record]) });
|
||||
try {
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
const controls = h.layer.getRowControls();
|
||||
assert.ok(controls.summary.detail.includes(` · ${label} · `));
|
||||
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
|
||||
assert.equal(record.classification, code);
|
||||
assert.equal(h.applied[0].storms[0].classification, code);
|
||||
} finally {
|
||||
h.layer.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
|
||||
const h = harness(
|
||||
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||
{ reducedMotion: true },
|
||||
);
|
||||
h.layer.enable();
|
||||
assert.equal(await h.layer.update(), true);
|
||||
const controls = h.layer.getRowControls();
|
||||
assert.equal(controls.readout, true);
|
||||
assert.deepEqual(controls.chips, []);
|
||||
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
|
||||
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
|
||||
assert.equal(controls.summary.lines.length, 3);
|
||||
assert.deepEqual(controls.summary.settings, []);
|
||||
assert.deepEqual(controls.summary.actions, [
|
||||
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
|
||||
]);
|
||||
assert.match(controls.summary.compact, /2 active storms · .* selected/);
|
||||
assert.equal(controls.summary.sections, undefined);
|
||||
assert.match(controls.list.ariaLabel, /NHC/);
|
||||
assert.equal(controls.list.items.length, 2);
|
||||
assert.equal(controls.list.items[0].active, true);
|
||||
assert.match(controls.info, /awaiting advisory 10/);
|
||||
assert.equal(
|
||||
controls.info.split('Track/cone awaiting advisory 10').length - 1,
|
||||
1,
|
||||
);
|
||||
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
|
||||
assert.match(controls.info, /09-16 03:00 UTC/);
|
||||
assert.match(controls.infoTitle, /not storm size/);
|
||||
assert.match(controls.infoTitle, /follows the surface/);
|
||||
assert.equal(controls.list.items[1].params.focus, true);
|
||||
assert.equal(
|
||||
controls.chips.some((chip) => chip.id === 'focus'),
|
||||
false,
|
||||
);
|
||||
h.layer.setParams(controls.list.items[1].params);
|
||||
assert.equal(h.selection, 'ep162026');
|
||||
assert.equal(h.navigation[0], 'claimed');
|
||||
assert.equal(h.navigation[1].options.duration, 0);
|
||||
h.layer.setParams(controls.list.items[1].params);
|
||||
assert.equal(h.navigation.length, 4, 'selected row focuses again');
|
||||
h.layer.setParams({ advisory: true });
|
||||
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
|
||||
h.layer.disable();
|
||||
h.layer.setParams({ focus: true, advisory: true });
|
||||
assert.equal(h.navigation.length, 4);
|
||||
assert.equal(h.opened.length, 1);
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
assert.equal(h.layer.getDiagnostics().timerActive, false);
|
||||
h.layer.destroy();
|
||||
assert.equal(h.destroyed, 1);
|
||||
});
|
||||
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
|
||||
let next = snapshot([]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().empty, true);
|
||||
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
|
||||
next = { ...snapshot(), stale: true };
|
||||
await h.layer.update();
|
||||
assert.match(h.layer.getRowControls().summary.status, /stale/);
|
||||
assert.match(
|
||||
h.layer.getRowControls().info,
|
||||
/Track\/cone awaiting advisory 10/,
|
||||
);
|
||||
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
|
||||
next = {
|
||||
...snapshot([]),
|
||||
unavailable: true,
|
||||
reason: 'Cyclone data unavailable',
|
||||
};
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().empty, false);
|
||||
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
|
||||
assert.ok(h.cleared > 0);
|
||||
h.layer.destroy();
|
||||
});
|
||||
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
|
||||
const first = deferred(),
|
||||
second = deferred();
|
||||
const signals = [];
|
||||
let calls = 0;
|
||||
const h = harness({
|
||||
getSnapshot: ({ signal }) => {
|
||||
signals.push(signal);
|
||||
return (++calls === 1 ? first : second).promise;
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
const old = h.layer.update(),
|
||||
latest = h.layer.update();
|
||||
assert.equal(signals[0].aborted, true);
|
||||
second.resolve(snapshot());
|
||||
assert.equal(await latest, true);
|
||||
first.resolve(snapshot([storm('ep162026')]));
|
||||
assert.equal(await old, false);
|
||||
assert.equal(h.applied.length, 1);
|
||||
assert.equal(h.selection, 'ep152026');
|
||||
h.layer.destroy();
|
||||
const delayed = deferred();
|
||||
const disabled = harness({ getSnapshot: () => delayed.promise });
|
||||
disabled.layer.enable();
|
||||
const work = disabled.layer.update();
|
||||
disabled.layer.disable();
|
||||
delayed.resolve(snapshot());
|
||||
assert.equal(await work, false);
|
||||
assert.equal(disabled.applied.length, 0);
|
||||
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
|
||||
disabled.layer.destroy();
|
||||
});
|
||||
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
|
||||
let fail = false;
|
||||
const h = harness({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('Network unavailable');
|
||||
return snapshot();
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
assert.equal(
|
||||
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
|
||||
false,
|
||||
);
|
||||
assert.equal(h.applied.length, 0);
|
||||
await h.layer.update();
|
||||
fail = true;
|
||||
await h.layer.update();
|
||||
assert.equal(h.layer.getStats().count, 0);
|
||||
assert.match(h.layer.getStats().error, /Network/);
|
||||
assert.ok(h.cleared > 0);
|
||||
h.layer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
clearOverlaySource,
|
||||
setOverlayEntries,
|
||||
setOverlaySourceVisible,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* @module cyclones/labels
|
||||
* @description Storm cards and lead-hour labels on the shared world-overlay
|
||||
* host. The renderer supplies the anchors its points are drawn and
|
||||
* horizon-culled at, plus the selection; the host owns projection, the same
|
||||
* ellipsoid horizon test, collisions, fades and paint.
|
||||
*/
|
||||
|
||||
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
|
||||
/** Lead-hour labels show within 4,000 km of the camera. */
|
||||
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
|
||||
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
|
||||
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
|
||||
/** Advisory centre and forecast track; single source for points and cards. */
|
||||
export const CYCLONE_ACCENT = '#7fe6ed';
|
||||
/** Selected advisory centre. */
|
||||
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
|
||||
/** Point sizes the cards keep clear of. */
|
||||
export const CYCLONE_MARKER_PX = Object.freeze({
|
||||
storm: 9,
|
||||
selected: 12,
|
||||
forecast: 5,
|
||||
});
|
||||
|
||||
const STORM_PRIORITY = 1000;
|
||||
const LEAD_PRIORITY = 500;
|
||||
|
||||
const CYCLONE_OVERLAY_HOST = Object.freeze({
|
||||
setEntries: setOverlayEntries,
|
||||
setVisible: setOverlaySourceVisible,
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
|
||||
function markerClearance(markerPx) {
|
||||
const gapPx = Math.max(12, markerPx + 8);
|
||||
return {
|
||||
gapPx,
|
||||
leaderOffsetPx: Math.max(2, gapPx - 6),
|
||||
verticalOnly: true,
|
||||
viewportMargin: 4,
|
||||
};
|
||||
}
|
||||
|
||||
const SHARED_CARD = Object.freeze({
|
||||
cardStyle: 'tactical',
|
||||
collisionGroup: 'ambient-card',
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// A click on a card selects its storm.
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* One card per storm: name, then the source classification code.
|
||||
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
|
||||
* @param {boolean} selected
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneStormEntry(storm, selected) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(
|
||||
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
|
||||
),
|
||||
id: `storm:${storm.id}`,
|
||||
position: storm.position,
|
||||
variant: selected ? 'selected' : 'card',
|
||||
title: storm.name,
|
||||
details: storm.classification ? [storm.classification] : [],
|
||||
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
|
||||
selected,
|
||||
protected: selected,
|
||||
priority: selected
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: STORM_PRIORITY + (Number(storm.windKt) || 0),
|
||||
maxDistance: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lead-hour label at one forecast point of the selected storm. Earlier lead
|
||||
* times win collisions.
|
||||
* @param {string} stormId
|
||||
* @param {{tauHours:number,position:object}} point
|
||||
* @returns {object}
|
||||
*/
|
||||
export function cycloneLeadEntry(stormId, point) {
|
||||
return {
|
||||
...SHARED_CARD,
|
||||
...markerClearance(CYCLONE_MARKER_PX.forecast),
|
||||
id: `lead:${stormId}:${point.tauHours}`,
|
||||
position: point.position,
|
||||
variant: 'card',
|
||||
title: `${point.tauHours} h`,
|
||||
details: [],
|
||||
accent: CYCLONE_ACCENT,
|
||||
selected: false,
|
||||
protected: false,
|
||||
priority: LEAD_PRIORITY - point.tauHours,
|
||||
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
|
||||
// Hard cutoff at the limit, without a fade.
|
||||
distanceFadeStartRatio: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Storm cards for every storm and lead-hour labels for the selected one.
|
||||
* @param {Array<object>} storms Renderer anchors with `forecasts`.
|
||||
* @param {?string} selectedId
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
export function cycloneOverlayEntries(storms, selectedId) {
|
||||
const entries = [];
|
||||
for (const storm of storms) {
|
||||
const selected = storm.id === selectedId;
|
||||
entries.push(cycloneStormEntry(storm, selected));
|
||||
if (!selected) continue;
|
||||
for (const point of storm.forecasts || [])
|
||||
entries.push(cycloneLeadEntry(storm.id, point));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Storm id behind a published card or lead-hour label id, else null. */
|
||||
export function cycloneStormIdFromEntryId(entryId) {
|
||||
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the renderer's committed anchors and selection to the overlay host.
|
||||
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
|
||||
*/
|
||||
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
|
||||
let storms = [];
|
||||
let selectedId = null;
|
||||
let published = false;
|
||||
function publish() {
|
||||
const entries = cycloneOverlayEntries(storms, selectedId);
|
||||
let ambient = 0;
|
||||
for (const entry of entries) if (!entry.protected) ambient++;
|
||||
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
|
||||
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
|
||||
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
|
||||
moving: false,
|
||||
});
|
||||
published = true;
|
||||
}
|
||||
return {
|
||||
/** Replace every anchor after a snapshot commits. */
|
||||
setSnapshot(nextStorms, nextSelectedId = selectedId) {
|
||||
storms = nextStorms;
|
||||
selectedId = nextSelectedId;
|
||||
publish();
|
||||
},
|
||||
/** Republish only when the selection changes a published snapshot. */
|
||||
setSelection(id) {
|
||||
if (id === selectedId) return;
|
||||
selectedId = id;
|
||||
if (published) publish();
|
||||
},
|
||||
clear() {
|
||||
storms = [];
|
||||
selectedId = null;
|
||||
if (!published) return;
|
||||
published = false;
|
||||
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
|
||||
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
|
||||
CYCLONE_OVERLAY_COHORT_LIMIT,
|
||||
CYCLONE_OVERLAY_SOURCE_ID,
|
||||
createCycloneLabels,
|
||||
cycloneOverlayEntries,
|
||||
cycloneStormIdFromEntryId,
|
||||
} from './labels.js';
|
||||
import {
|
||||
normalizeOverlayEntry,
|
||||
paintLaneForOverlayEntry,
|
||||
WORLD_OVERLAY_PAINT_LANES,
|
||||
} from '../../overlays/worldOverlay.js';
|
||||
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
|
||||
|
||||
const anchor = (longitude, latitude) =>
|
||||
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
|
||||
const storms = () => [
|
||||
{
|
||||
id: 'al062026',
|
||||
name: 'Fay',
|
||||
classification: 'TS',
|
||||
windKt: 50,
|
||||
position: anchor(-60, 20),
|
||||
forecasts: [
|
||||
{ tauHours: 12, position: anchor(-61, 21) },
|
||||
{ tauHours: 24, position: anchor(-62, 22) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
classification: 'PTC',
|
||||
windKt: null,
|
||||
position: anchor(-125, 15),
|
||||
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
|
||||
},
|
||||
];
|
||||
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
|
||||
|
||||
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
|
||||
const input = storms();
|
||||
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
|
||||
assert.deepEqual(
|
||||
[...entries.keys()],
|
||||
[
|
||||
'storm:al062026',
|
||||
'lead:al062026:12',
|
||||
'lead:al062026:24',
|
||||
'storm:ep152026',
|
||||
],
|
||||
'lead-hour labels belong to the selected storm only',
|
||||
);
|
||||
const selected = entries.get('storm:al062026');
|
||||
const ambient = entries.get('storm:ep152026');
|
||||
for (const card of [selected, ambient]) {
|
||||
assert.equal(card.cardStyle, 'tactical');
|
||||
assert.equal(card.collisionGroup, 'ambient-card');
|
||||
assert.equal(card.verticalOnly, true);
|
||||
assert.equal(card.horizonCull, true);
|
||||
assert.equal(card.edgeFade, 'keyhole');
|
||||
assert.equal(card.interactive, true);
|
||||
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
|
||||
}
|
||||
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
|
||||
assert.equal(selected.title, 'Fay');
|
||||
assert.deepEqual(selected.details, ['TS']);
|
||||
assert.equal(selected.variant, 'selected');
|
||||
assert.equal(selected.selected, true);
|
||||
assert.equal(selected.protected, true);
|
||||
assert.equal(selected.accent, '#ffe19a');
|
||||
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
|
||||
assert.equal(selected.gapPx, 20);
|
||||
assert.equal(selected.leaderOffsetPx, 14);
|
||||
assert.equal(ambient.title, 'Fifteen-E');
|
||||
assert.deepEqual(ambient.details, ['PTC']);
|
||||
assert.equal(ambient.variant, 'card');
|
||||
assert.equal(ambient.protected, false);
|
||||
assert.equal(ambient.accent, '#7fe6ed');
|
||||
assert.equal(ambient.gapPx, 17);
|
||||
assert.equal(ambient.leaderOffsetPx, 11);
|
||||
|
||||
const lead = entries.get('lead:al062026:24');
|
||||
assert.equal(lead.position, input[0].forecasts[1].position);
|
||||
assert.equal(lead.title, '24 h');
|
||||
assert.deepEqual(lead.details, []);
|
||||
assert.equal(lead.cardStyle, 'tactical');
|
||||
assert.equal(lead.collisionGroup, 'ambient-card');
|
||||
assert.equal(lead.protected, false);
|
||||
assert.equal(lead.interactive, true);
|
||||
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
|
||||
assert.equal(lead.gapPx, 13);
|
||||
assert.equal(lead.leaderOffsetPx, 7);
|
||||
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
|
||||
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
|
||||
|
||||
const none = cycloneOverlayEntries(input, null);
|
||||
assert.deepEqual(
|
||||
none.map((entry) => [entry.id, entry.variant]),
|
||||
[
|
||||
['storm:al062026', 'card'],
|
||||
['storm:ep152026', 'card'],
|
||||
],
|
||||
);
|
||||
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
|
||||
});
|
||||
|
||||
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
|
||||
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
|
||||
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
|
||||
);
|
||||
const lane = (entry) =>
|
||||
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
|
||||
const [selected, lead] = entries;
|
||||
assert.equal(lane(selected), 'selected');
|
||||
assert.equal(lane(lead), 'ambient-card');
|
||||
assert.equal(lane(entries.at(-1)), 'ambient-card');
|
||||
for (const entry of entries) {
|
||||
assert.equal(entry.collisionGroup, 'ambient-card');
|
||||
assert.equal(entry.cardStyle, 'tactical');
|
||||
assert.equal(entry.horizonCull, true);
|
||||
}
|
||||
const range = {
|
||||
minDistance: lead.minDistance,
|
||||
maxDistance: lead.maxDistance,
|
||||
fadeStartRatio: lead.distanceFadeStartRatio,
|
||||
};
|
||||
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
|
||||
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
|
||||
assert.equal(
|
||||
distanceFade(40_000_000, {
|
||||
minDistance: selected.minDistance,
|
||||
maxDistance: selected.maxDistance,
|
||||
fadeStartRatio: selected.distanceFadeStartRatio,
|
||||
}),
|
||||
1,
|
||||
'storm cards have no distance limit',
|
||||
);
|
||||
});
|
||||
|
||||
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
|
||||
const calls = [];
|
||||
const host = {
|
||||
setEntries: (...args) => calls.push(['entries', ...args]),
|
||||
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||
};
|
||||
const labels = createCycloneLabels({ host });
|
||||
labels.setSelection('al062026');
|
||||
labels.clear();
|
||||
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
|
||||
|
||||
labels.setSnapshot(storms(), 'al062026');
|
||||
assert.deepEqual(
|
||||
calls.map(([kind, source]) => [kind, source]),
|
||||
[
|
||||
['visible', CYCLONE_OVERLAY_SOURCE_ID],
|
||||
['entries', CYCLONE_OVERLAY_SOURCE_ID],
|
||||
],
|
||||
);
|
||||
assert.equal(calls[0][2], true);
|
||||
const [, , entries, options] = calls[1];
|
||||
assert.equal(entries.length, 4);
|
||||
assert.deepEqual(options, {
|
||||
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: 3,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
calls.length = 0;
|
||||
labels.setSelection('al062026');
|
||||
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
|
||||
labels.setSelection('ep152026');
|
||||
const published = calls.find(([kind]) => kind === 'entries')[2];
|
||||
assert.deepEqual(
|
||||
published.map((entry) => entry.id),
|
||||
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
|
||||
);
|
||||
assert.equal(published[1].variant, 'selected');
|
||||
|
||||
calls.length = 0;
|
||||
labels.clear();
|
||||
assert.deepEqual(calls, [
|
||||
['clear', CYCLONE_OVERLAY_SOURCE_ID],
|
||||
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
|
||||
]);
|
||||
labels.clear();
|
||||
labels.setSelection('al062026');
|
||||
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
|
||||
});
|
||||
|
||||
test('storm identity resolves from card and lead-hour ids only', () => {
|
||||
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
|
||||
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
|
||||
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
|
||||
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
CYCLONE_ACCENT,
|
||||
CYCLONE_MARKER_PX,
|
||||
CYCLONE_SELECTED_ACCENT,
|
||||
createCycloneLabels,
|
||||
} from './labels.js';
|
||||
|
||||
/** Only geometry from the displayed status advisory is eligible for rendering. */
|
||||
export function coherentCycloneGeometry(storm) {
|
||||
return (
|
||||
storm.geometryStatus === 'current' &&
|
||||
storm.geometryAdvisoryNumber === storm.advisoryNumber
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static Cesium entities with owned horizon culling; no timers or clock.
|
||||
* Storm names and lead hours are published to the shared overlay host at the
|
||||
* same anchors the points use.
|
||||
*/
|
||||
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
|
||||
const labels = createCycloneLabels({ host: overlayHost });
|
||||
let source = null,
|
||||
generation = 0,
|
||||
selected = null,
|
||||
destroyed = false;
|
||||
let centers = new Map(),
|
||||
spheres = new Map();
|
||||
let entityStorms = new WeakMap();
|
||||
let entityIds = new Set();
|
||||
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
|
||||
let horizonStorms = [],
|
||||
removePreRender = null,
|
||||
pointOccluder = null,
|
||||
sphereOccluder = null;
|
||||
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
|
||||
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
|
||||
const white = C.Color.WHITE;
|
||||
const render = () => {
|
||||
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
|
||||
};
|
||||
function cullHorizon() {
|
||||
pointOccluder.cameraPosition = viewer.camera.positionWC;
|
||||
sphereOccluder.cameraPosition = viewer.camera.positionWC;
|
||||
let changed = false;
|
||||
for (let i = 0; i < horizonStorms.length; i++) {
|
||||
const storm = horizonStorms[i];
|
||||
for (let j = 0; j < storm.points.length; j++) {
|
||||
const { entity, position } = storm.points[j];
|
||||
const show = pointOccluder.isPointVisible(position);
|
||||
if (entity.show !== show) {
|
||||
entity.show = show;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!storm.shapes.length) continue;
|
||||
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
|
||||
for (let j = 0; j < storm.shapes.length; j++) {
|
||||
const entity = storm.shapes[j];
|
||||
if (entity.show !== show) {
|
||||
entity.show = show;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) render();
|
||||
}
|
||||
function syncHorizonListener() {
|
||||
if (!horizonStorms.length) {
|
||||
removePreRender?.();
|
||||
removePreRender = null;
|
||||
return;
|
||||
}
|
||||
if (removePreRender) return;
|
||||
pointOccluder ||= new C.EllipsoidalOccluder(
|
||||
C.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
|
||||
// conservatively culls extents, retaining partially visible tracks/cones.
|
||||
sphereOccluder ||= new C.Occluder(
|
||||
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
|
||||
}
|
||||
function remove(value) {
|
||||
if (!value) return;
|
||||
if (!viewer.dataSources.isDestroyed?.())
|
||||
viewer.dataSources.remove(value, true);
|
||||
value.entities.removeAll();
|
||||
}
|
||||
function select(id) {
|
||||
selected = id;
|
||||
for (const [stormId, entity] of centers) {
|
||||
entity.point.color = stormId === id ? gold : blue;
|
||||
entity.point.pixelSize =
|
||||
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
|
||||
}
|
||||
labels.setSelection(id);
|
||||
render();
|
||||
}
|
||||
return {
|
||||
async setSnapshot(snapshot, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
if (destroyed) return false;
|
||||
const owner = ++generation;
|
||||
const next = new C.CustomDataSource('weather-cyclones');
|
||||
const nextCenters = new Map(),
|
||||
nextSpheres = new Map();
|
||||
const nextEntityStorms = new WeakMap();
|
||||
const nextEntityIds = new Set();
|
||||
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
|
||||
const nextHorizonStorms = [];
|
||||
const nextLabels = [];
|
||||
const position = ({ longitude, latitude }) =>
|
||||
C.Cartesian3.fromDegrees(longitude, latitude, 0);
|
||||
const coordinate = (pair) =>
|
||||
position({ longitude: pair[0], latitude: pair[1] });
|
||||
try {
|
||||
for (const storm of snapshot.storms) {
|
||||
const horizon = { points: [], shapes: [], sphere: null };
|
||||
const addEntity = (options) => {
|
||||
const entity = next.entities.add(options);
|
||||
if (options.position)
|
||||
horizon.points.push({ entity, position: options.position });
|
||||
else horizon.shapes.push(entity);
|
||||
nextEntityStorms.set(entity, storm.id);
|
||||
nextEntityIds.add(entity.id);
|
||||
return entity;
|
||||
};
|
||||
const center = position(storm.position),
|
||||
extent = [center];
|
||||
const entity = addEntity({
|
||||
id: `cyclone:${storm.id}:center`,
|
||||
name: storm.name,
|
||||
position: center,
|
||||
point: {
|
||||
heightReference: C.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
pixelSize: CYCLONE_MARKER_PX.storm,
|
||||
color: blue,
|
||||
outlineColor: C.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
},
|
||||
});
|
||||
nextCenters.set(storm.id, entity);
|
||||
const label = {
|
||||
id: storm.id,
|
||||
name: storm.name,
|
||||
classification: storm.classification,
|
||||
windKt: storm.windKt,
|
||||
position: center,
|
||||
forecasts: [],
|
||||
};
|
||||
nextLabels.push(label);
|
||||
nextCounts.storms++;
|
||||
if (coherentCycloneGeometry(storm)) {
|
||||
const lines =
|
||||
storm.track?.type === 'LineString'
|
||||
? [storm.track.coordinates]
|
||||
: storm.track?.coordinates || [];
|
||||
lines.forEach((line, index) => {
|
||||
const positions = line.map(coordinate);
|
||||
extent.push(...positions);
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:track:${index}`,
|
||||
polyline: {
|
||||
clampToGround: true,
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
positions,
|
||||
width: 2.5,
|
||||
material: blue,
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
nextCounts.tracks++;
|
||||
});
|
||||
const polygons =
|
||||
storm.cone?.type === 'Polygon'
|
||||
? [storm.cone.coordinates]
|
||||
: storm.cone?.coordinates || [];
|
||||
polygons.forEach((rings, index) => {
|
||||
// Each exterior retains its own interior holes. Native geographic
|
||||
// tessellation handles the antimeridian; never flatten rings.
|
||||
const exterior = rings[0].map(coordinate);
|
||||
extent.push(...exterior);
|
||||
const holes = rings
|
||||
.slice(1)
|
||||
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:cone:${index}`,
|
||||
polygon: {
|
||||
hierarchy: new C.PolygonHierarchy(exterior, holes),
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
material: blue.withAlpha(0.16),
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
[exterior, ...holes.map((hole) => hole.positions)].forEach(
|
||||
(positions, ringIndex) => {
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
|
||||
polyline: {
|
||||
positions,
|
||||
width: 1,
|
||||
material: blue.withAlpha(0.55),
|
||||
arcType: C.ArcType.GEODESIC,
|
||||
clampToGround: true,
|
||||
classificationType: C.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
nextCounts.cones++;
|
||||
});
|
||||
for (const [index, point] of storm.forecastPoints.entries()) {
|
||||
if (point.tauHours === 0) continue;
|
||||
const p = position(point.position);
|
||||
extent.push(p);
|
||||
addEntity({
|
||||
id: `cyclone:${storm.id}:forecast:${index}`,
|
||||
position: p,
|
||||
point: {
|
||||
heightReference: C.HeightReference.CLAMP_TO_GROUND,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
pixelSize: CYCLONE_MARKER_PX.forecast,
|
||||
color: white,
|
||||
outlineColor: C.Color.BLACK,
|
||||
outlineWidth: 1,
|
||||
},
|
||||
});
|
||||
label.forecasts.push({ tauHours: point.tauHours, position: p });
|
||||
nextCounts.forecastPoints++;
|
||||
}
|
||||
}
|
||||
const sphere = C.BoundingSphere.fromPoints(extent);
|
||||
// A status-only point still has a useful regional camera destination.
|
||||
sphere.radius = Math.max(sphere.radius, 500_000);
|
||||
nextSpheres.set(storm.id, sphere);
|
||||
horizon.sphere = sphere;
|
||||
nextHorizonStorms.push(horizon);
|
||||
}
|
||||
await viewer.dataSources.add(next);
|
||||
if (destroyed || generation !== owner || signal?.aborted) {
|
||||
remove(next);
|
||||
return false;
|
||||
}
|
||||
remove(source);
|
||||
source = next;
|
||||
centers = nextCenters;
|
||||
spheres = nextSpheres;
|
||||
entityStorms = nextEntityStorms;
|
||||
entityIds = nextEntityIds;
|
||||
counts = nextCounts;
|
||||
horizonStorms = nextHorizonStorms;
|
||||
syncHorizonListener();
|
||||
labels.setSnapshot(nextLabels, selected);
|
||||
select(selected);
|
||||
return true;
|
||||
} catch (error) {
|
||||
remove(next);
|
||||
if (signal?.aborted || generation !== owner || destroyed) return false;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
setSelection: select,
|
||||
ownsPickId(id) {
|
||||
return source !== null && typeof id === 'string' && entityIds.has(id);
|
||||
},
|
||||
pickStorm(picked) {
|
||||
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
|
||||
// cannot establish ownership, especially after an advisory replacement.
|
||||
const entity = picked?.id;
|
||||
return source && entity && typeof entity === 'object'
|
||||
? entityStorms.get(entity) || null
|
||||
: null;
|
||||
},
|
||||
getFocusSphere(id) {
|
||||
return spheres.get(id) || null;
|
||||
},
|
||||
clear() {
|
||||
++generation;
|
||||
horizonStorms = [];
|
||||
syncHorizonListener();
|
||||
pointOccluder = null;
|
||||
sphereOccluder = null;
|
||||
remove(source);
|
||||
source = null;
|
||||
labels.clear();
|
||||
centers.clear();
|
||||
spheres.clear();
|
||||
entityStorms = new WeakMap();
|
||||
entityIds.clear();
|
||||
selected = null;
|
||||
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
|
||||
render();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
this.clear();
|
||||
destroyed = true;
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
...counts,
|
||||
dataSources: Number(!!source),
|
||||
entities: source?.entities.values.length || 0,
|
||||
selectedId: selected,
|
||||
timerActive: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCycloneRendering } from './rendering.js';
|
||||
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
|
||||
|
||||
/** Records what the renderer publishes to the shared overlay host. */
|
||||
function overlayRecorder() {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
host: {
|
||||
setEntries: (source, entries, options) =>
|
||||
calls.push({ kind: 'entries', source, entries, options }),
|
||||
setVisible: (source, visible) =>
|
||||
calls.push({ kind: 'visible', source, visible }),
|
||||
clearSource: (source) => calls.push({ kind: 'clear', source }),
|
||||
},
|
||||
get publishes() {
|
||||
return calls.filter(({ kind }) => kind === 'entries').length;
|
||||
},
|
||||
published() {
|
||||
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
|
||||
},
|
||||
entry(id) {
|
||||
return this.published().find((entry) => entry.id === id) || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function harness({ deferred = false } = {}) {
|
||||
const sources = [],
|
||||
completions = [],
|
||||
pointOccluders = [],
|
||||
sphereOccluders = [];
|
||||
const listeners = new Set();
|
||||
const visibility = {
|
||||
points: new Map(),
|
||||
spheres: new Map(),
|
||||
pointCalls: [],
|
||||
sphereCalls: [],
|
||||
writes: 0,
|
||||
};
|
||||
const color = (value) => ({
|
||||
value,
|
||||
withAlpha: (alpha) => ({ value, alpha }),
|
||||
});
|
||||
const cesium = {
|
||||
Color: {
|
||||
fromCssColorString: color,
|
||||
WHITE: color('white'),
|
||||
BLACK: color('black'),
|
||||
},
|
||||
Cartesian2: class {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
},
|
||||
Cartesian3: {
|
||||
ZERO: { x: 0, y: 0, z: 0 },
|
||||
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
|
||||
},
|
||||
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
|
||||
EllipsoidalOccluder: class {
|
||||
constructor(ellipsoid, cameraPosition) {
|
||||
this.ellipsoid = ellipsoid;
|
||||
this.cameraPosition = cameraPosition;
|
||||
pointOccluders.push(this);
|
||||
}
|
||||
isPointVisible(position) {
|
||||
visibility.pointCalls.push(position);
|
||||
return visibility.points.get(position) ?? true;
|
||||
}
|
||||
},
|
||||
Occluder: class {
|
||||
constructor(sphere, cameraPosition) {
|
||||
this.sphere = sphere;
|
||||
this.cameraPosition = cameraPosition;
|
||||
sphereOccluders.push(this);
|
||||
}
|
||||
isBoundingSphereVisible(sphere) {
|
||||
visibility.sphereCalls.push(sphere);
|
||||
return visibility.spheres.get(sphere) ?? true;
|
||||
}
|
||||
},
|
||||
PolygonHierarchy: class {
|
||||
constructor(positions, holes = []) {
|
||||
this.positions = positions;
|
||||
this.holes = holes;
|
||||
}
|
||||
},
|
||||
BoundingSphere: class {
|
||||
constructor(center, radius) {
|
||||
this.center = center;
|
||||
this.radius = radius;
|
||||
}
|
||||
static fromPoints(points) {
|
||||
return { points, radius: 10 };
|
||||
}
|
||||
},
|
||||
LabelStyle: { FILL_AND_OUTLINE: 1 },
|
||||
HorizontalOrigin: { LEFT: 1 },
|
||||
ArcType: { GEODESIC: 1 },
|
||||
HeightReference: Cesium.HeightReference,
|
||||
ClassificationType: Cesium.ClassificationType,
|
||||
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
|
||||
CustomDataSource: class {
|
||||
constructor() {
|
||||
const values = [];
|
||||
this.entities = {
|
||||
values,
|
||||
add: (value) => {
|
||||
let show = true;
|
||||
Object.defineProperty(value, 'show', {
|
||||
get: () => show,
|
||||
set: (next) => {
|
||||
show = next;
|
||||
visibility.writes++;
|
||||
},
|
||||
});
|
||||
values.push(value);
|
||||
return value;
|
||||
},
|
||||
removeAll: () => {
|
||||
values.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
let renders = 0;
|
||||
const overlay = overlayRecorder();
|
||||
const viewer = {
|
||||
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
|
||||
scene: {
|
||||
requestRender: () => renders++,
|
||||
preRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
},
|
||||
dataSources: {
|
||||
add(value) {
|
||||
if (deferred)
|
||||
return new Promise((resolve) =>
|
||||
completions.push(() => {
|
||||
sources.push(value);
|
||||
resolve(value);
|
||||
}),
|
||||
);
|
||||
sources.push(value);
|
||||
return Promise.resolve(value);
|
||||
},
|
||||
remove(value) {
|
||||
const i = sources.indexOf(value);
|
||||
if (i >= 0) sources.splice(i, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
rendering: createCycloneRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
overlayHost: overlay.host,
|
||||
}),
|
||||
overlay,
|
||||
sources,
|
||||
completions,
|
||||
viewer,
|
||||
cesium,
|
||||
visibility,
|
||||
pointOccluders,
|
||||
sphereOccluders,
|
||||
listeners,
|
||||
frame() {
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
get renders() {
|
||||
return renders;
|
||||
},
|
||||
};
|
||||
}
|
||||
const storm = () => ({
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
advisoryNumber: '10',
|
||||
geometryAdvisoryNumber: '10',
|
||||
geometryStatus: 'current',
|
||||
position: { longitude: 179, latitude: 15 },
|
||||
forecastPoints: [
|
||||
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
|
||||
],
|
||||
track: {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[179, 15],
|
||||
[-179, 16],
|
||||
],
|
||||
],
|
||||
},
|
||||
cone: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [
|
||||
[
|
||||
[
|
||||
[178, 10],
|
||||
[-178, 10],
|
||||
[-178, 20],
|
||||
[178, 10],
|
||||
],
|
||||
[
|
||||
[179, 12],
|
||||
[-179, 12],
|
||||
[-179, 14],
|
||||
[179, 12],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[170, 1],
|
||||
[171, 1],
|
||||
[171, 2],
|
||||
[170, 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test('horizon culling updates only changed entities and keeps selection independent', async () => {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({
|
||||
storms: [storm(), { ...storm(), id: 'near' }],
|
||||
});
|
||||
const entities = h.sources[0].entities.values;
|
||||
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
|
||||
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
|
||||
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
|
||||
const sphere = h.rendering.getFocusSphere('ep152026');
|
||||
for (const entity of far)
|
||||
if (entity.position) h.visibility.points.set(entity.position, false);
|
||||
h.visibility.spheres.set(sphere, false);
|
||||
h.rendering.setSelection('ep152026');
|
||||
const publishes = h.overlay.publishes;
|
||||
const before = h.renders;
|
||||
h.frame();
|
||||
assert.ok(far.every((e) => e.show === false));
|
||||
assert.ok(near.every((e) => e.show === true));
|
||||
assert.equal(
|
||||
h.overlay.entry('lead:ep152026:12').position,
|
||||
forecast.position,
|
||||
'the host culls labels at the same anchors the points use',
|
||||
);
|
||||
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
|
||||
assert.equal(h.renders, before + 1);
|
||||
assert.equal(h.visibility.writes, far.length);
|
||||
assert.equal(h.visibility.pointCalls.length, 4);
|
||||
assert.deepEqual(h.visibility.sphereCalls, [
|
||||
sphere,
|
||||
h.rendering.getFocusSphere('near'),
|
||||
]);
|
||||
h.frame();
|
||||
assert.equal(h.renders, before + 1);
|
||||
assert.equal(h.visibility.writes, far.length);
|
||||
|
||||
// A visible portion of the extent keeps all tracks/cones shown even when
|
||||
// the centre and an individual forecast point remain beyond the horizon.
|
||||
h.visibility.spheres.set(sphere, true);
|
||||
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
|
||||
h.frame();
|
||||
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
|
||||
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
|
||||
assert.equal(h.renders, before + 2);
|
||||
assert.equal(h.pointOccluders.length, 1);
|
||||
assert.equal(h.sphereOccluders.length, 1);
|
||||
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
|
||||
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
|
||||
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
|
||||
|
||||
h.rendering.setSelection('near');
|
||||
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
|
||||
assert.equal(
|
||||
h.overlay.entry('lead:near:12').position,
|
||||
near.find((e) => e.id.endsWith('forecast:0')).position,
|
||||
);
|
||||
h.visibility.points.set(forecast.position, true);
|
||||
const beforeReveal = h.renders;
|
||||
const revealPublishes = h.overlay.publishes;
|
||||
h.frame();
|
||||
assert.equal(forecast.show, true);
|
||||
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
|
||||
assert.equal(h.overlay.publishes, revealPublishes);
|
||||
assert.equal(far[0].show, false);
|
||||
assert.equal(h.renders, beforeReveal + 1);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
|
||||
const h = harness();
|
||||
assert.equal(h.listeners.size, 0);
|
||||
await h.rendering.setSnapshot({ storms: [] });
|
||||
assert.equal(h.listeners.size, 0);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 1);
|
||||
const old = [...h.sources[0].entities.values];
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 1);
|
||||
h.frame();
|
||||
assert.ok(
|
||||
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
|
||||
);
|
||||
await h.rendering.setSnapshot({ storms: [] });
|
||||
assert.equal(h.listeners.size, 0);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.rendering.clear();
|
||||
assert.equal(h.listeners.size, 0);
|
||||
const before = h.renders;
|
||||
h.frame();
|
||||
assert.equal(h.renders, before);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 1);
|
||||
h.rendering.destroy();
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.listeners.size, 0);
|
||||
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
|
||||
assert.equal(h.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
|
||||
for (const method of ['clear', 'destroy']) {
|
||||
const h = harness({ deferred: true });
|
||||
const pending = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.listeners.size, 0);
|
||||
h.rendering[method]();
|
||||
h.completions.shift()();
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.listeners.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
|
||||
for (const globeShow of [true, false]) {
|
||||
const sources = [];
|
||||
const viewer = {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
|
||||
scene: {
|
||||
globe: { show: globeShow },
|
||||
preRender: new Cesium.Event(),
|
||||
requestRender() {},
|
||||
},
|
||||
dataSources: {
|
||||
async add(source) {
|
||||
sources.push(source);
|
||||
},
|
||||
remove() {},
|
||||
},
|
||||
};
|
||||
const overlay = overlayRecorder();
|
||||
const rendering = createCycloneRendering({
|
||||
viewer,
|
||||
cesium: Cesium,
|
||||
overlayHost: overlay.host,
|
||||
});
|
||||
const at = (id, longitude) => ({
|
||||
...storm(),
|
||||
id,
|
||||
position: { longitude, latitude: 0 },
|
||||
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
|
||||
track: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[longitude, 0],
|
||||
[longitude, 1],
|
||||
],
|
||||
},
|
||||
cone: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[longitude, 0],
|
||||
[longitude + 1, 0],
|
||||
[longitude, 1],
|
||||
[longitude, 0],
|
||||
],
|
||||
],
|
||||
},
|
||||
});
|
||||
await rendering.setSnapshot({
|
||||
storms: [at('near', 0), at('far', 180), at('limb', 5)],
|
||||
});
|
||||
rendering.setSelection('near');
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
const entities = sources[0].entities.values;
|
||||
for (const entity of entities.filter((e) => e.position)) {
|
||||
assert.equal(
|
||||
entity.point.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
|
||||
assert.equal(entity.label, undefined, 'no Cesium label graphics');
|
||||
const published = overlay.entry(
|
||||
entity.id.includes(':forecast:')
|
||||
? `lead:${entity.id.split(':')[1]}:24`
|
||||
: `storm:${entity.id.split(':')[1]}`,
|
||||
);
|
||||
if (entity.id.startsWith('cyclone:near:')) {
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equals(
|
||||
published.position,
|
||||
entity.position.getValue(),
|
||||
),
|
||||
);
|
||||
assert.equal(published.horizonCull, true);
|
||||
}
|
||||
if (entity.id === 'cyclone:near:forecast:0')
|
||||
assert.equal(published.maxDistance, 4_000_000);
|
||||
}
|
||||
assert.ok(
|
||||
entities
|
||||
.filter((e) => e.id.startsWith('cyclone:near:'))
|
||||
.every((e) => e.show),
|
||||
);
|
||||
assert.ok(
|
||||
entities
|
||||
.filter((e) => e.id.startsWith('cyclone:far:'))
|
||||
.every((e) => !e.show),
|
||||
);
|
||||
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
|
||||
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
|
||||
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
|
||||
rendering.destroy();
|
||||
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
const oldEntities = [...h.sources[0].entities.values];
|
||||
assert.equal(oldEntities.length, 8);
|
||||
for (const entity of oldEntities) {
|
||||
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
|
||||
assert.equal(h.rendering.ownsPickId(entity.id), true);
|
||||
assert.equal(
|
||||
h.rendering.pickStorm({ id: { ...entity } }),
|
||||
null,
|
||||
'copied ID is not ownership',
|
||||
);
|
||||
assert.equal(
|
||||
h.rendering.pickStorm({ id: entity.id }),
|
||||
null,
|
||||
'string prefix is not ownership',
|
||||
);
|
||||
}
|
||||
assert.equal(h.rendering.pickStorm(undefined), null);
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
for (const entity of oldEntities)
|
||||
assert.equal(h.rendering.pickStorm({ id: entity }), null);
|
||||
const current = h.sources[0].entities.values[0];
|
||||
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
|
||||
h.rendering.clear();
|
||||
assert.equal(h.rendering.pickStorm({ id: current }), null);
|
||||
assert.equal(h.rendering.ownsPickId(current.id), false);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('registry IDs switch only when the next coherent data source commits', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const first = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
|
||||
h.completions.shift()();
|
||||
await first;
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
|
||||
const next = h.rendering.setSnapshot({
|
||||
storms: [{ ...storm(), id: 'ep162026' }],
|
||||
});
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
|
||||
h.completions.shift()();
|
||||
await next;
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
|
||||
});
|
||||
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({ storms: [storm()] });
|
||||
const entities = h.sources[0].entities.values;
|
||||
const cones = entities.filter((e) => e.polygon);
|
||||
assert.equal(cones.length, 2);
|
||||
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
|
||||
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
|
||||
for (const cone of cones) {
|
||||
assert.equal(
|
||||
cone.polygon.classificationType,
|
||||
h.cesium.ClassificationType.BOTH,
|
||||
);
|
||||
assert.equal(cone.polygon.height, undefined);
|
||||
assert.equal(cone.polygon.extrudedHeight, undefined);
|
||||
assert.equal(cone.polygon.outline, undefined);
|
||||
}
|
||||
const outlines = entities.filter((e) => e.id.includes(':outline:'));
|
||||
assert.equal(outlines.length, 3);
|
||||
assert.deepEqual(
|
||||
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
|
||||
storm().cone.coordinates.flat(),
|
||||
);
|
||||
for (const outline of outlines) {
|
||||
assert.deepEqual(outline.polyline.material, {
|
||||
value: '#7fe6ed',
|
||||
alpha: 0.55,
|
||||
});
|
||||
}
|
||||
for (const { polyline } of entities.filter((e) => e.polyline)) {
|
||||
assert.equal(polyline.clampToGround, true);
|
||||
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
|
||||
assert.ok(polyline.positions.every((p) => p.height === 0));
|
||||
}
|
||||
assert.equal(
|
||||
entities.find((e) => e.polyline).polyline.positions[1].lon,
|
||||
-179,
|
||||
);
|
||||
assert.deepEqual(h.rendering.getDiagnostics(), {
|
||||
storms: 1,
|
||||
tracks: 1,
|
||||
cones: 2,
|
||||
forecastPoints: 1,
|
||||
dataSources: 1,
|
||||
entities: 8,
|
||||
selectedId: null,
|
||||
timerActive: false,
|
||||
});
|
||||
h.rendering.setSelection('ep152026');
|
||||
assert.equal(
|
||||
h.overlay.entry('lead:ep152026:12').position,
|
||||
entities.find((e) => e.id.endsWith('forecast:0')).position,
|
||||
);
|
||||
assert.ok(entities.every((e) => e.label === undefined));
|
||||
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.sources.length, 0);
|
||||
assert.equal(h.rendering.getDiagnostics().entities, 0);
|
||||
});
|
||||
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
|
||||
for (const changed of [
|
||||
{ geometryStatus: 'pending' },
|
||||
{ geometryAdvisoryNumber: '9' },
|
||||
]) {
|
||||
const h = harness();
|
||||
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
|
||||
assert.equal(h.sources[0].entities.values.length, 1);
|
||||
assert.equal(h.rendering.getDiagnostics().tracks, 0);
|
||||
h.rendering.destroy();
|
||||
}
|
||||
});
|
||||
test('a data-source add settling after disable is removed, without a new owner', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const pending = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.rendering.clear();
|
||||
h.completions.shift()();
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(h.sources.length, 0);
|
||||
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
|
||||
});
|
||||
test('superseded asynchronous additions cannot replace newer geometry', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const old = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
const latest = h.rendering.setSnapshot({ storms: [] });
|
||||
h.completions[1]();
|
||||
assert.equal(await latest, true);
|
||||
h.completions[0]();
|
||||
assert.equal(await old, false);
|
||||
assert.equal(h.sources.length, 1);
|
||||
assert.equal(h.rendering.getDiagnostics().storms, 0);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
test('aborting a pending add retains the prior complete source', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const initial = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.completions.shift()();
|
||||
await initial;
|
||||
const controller = new AbortController();
|
||||
const update = h.rendering.setSnapshot(
|
||||
{ storms: [] },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
controller.abort();
|
||||
h.completions.shift()();
|
||||
assert.equal(await update, false);
|
||||
assert.equal(h.sources.length, 1);
|
||||
assert.equal(h.rendering.getDiagnostics().storms, 1);
|
||||
h.rendering.destroy();
|
||||
});
|
||||
|
||||
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
|
||||
const h = harness({ deferred: true });
|
||||
const pending = h.rendering.setSnapshot({
|
||||
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
|
||||
});
|
||||
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
|
||||
h.completions.shift()();
|
||||
assert.equal(await pending, true);
|
||||
const entities = h.sources[0].entities.values;
|
||||
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
|
||||
assert.deepEqual(
|
||||
h.overlay.calls
|
||||
.slice(0, 2)
|
||||
.map(({ kind, source, visible }) => [kind, source, visible]),
|
||||
[
|
||||
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
|
||||
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
h.overlay.published().map((entry) => entry.id),
|
||||
['storm:ep152026', 'storm:near'],
|
||||
'no selection, no lead-hour labels',
|
||||
);
|
||||
assert.equal(h.overlay.entry('storm:near').title, 'Near');
|
||||
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
|
||||
|
||||
h.rendering.setSelection('near');
|
||||
assert.equal(center('near').point.pixelSize, 12);
|
||||
assert.equal(center('ep152026').point.pixelSize, 9);
|
||||
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
|
||||
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
|
||||
assert.ok(h.overlay.entry('lead:near:12'));
|
||||
const publishes = h.overlay.publishes;
|
||||
h.rendering.setSelection('near');
|
||||
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
|
||||
|
||||
// A refresh republishes the new anchors with the retained selection.
|
||||
const refresh = h.rendering.setSnapshot({
|
||||
storms: [{ ...storm(), id: 'near', name: 'Near' }],
|
||||
});
|
||||
h.completions.shift()();
|
||||
await refresh;
|
||||
assert.deepEqual(
|
||||
h.overlay.published().map((entry) => [entry.id, entry.variant]),
|
||||
[
|
||||
['storm:near', 'selected'],
|
||||
['lead:near:12', 'card'],
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
h.overlay.entry('storm:near').position,
|
||||
h.sources[0].entities.values[0].position,
|
||||
);
|
||||
|
||||
// A superseded addition never publishes, and clear hides the source.
|
||||
const stale = h.rendering.setSnapshot({ storms: [storm()] });
|
||||
h.rendering.clear();
|
||||
const afterClear = h.overlay.calls.slice(-2);
|
||||
assert.deepEqual(afterClear, [
|
||||
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
|
||||
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
|
||||
]);
|
||||
h.completions.shift()();
|
||||
assert.equal(await stale, false);
|
||||
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
|
||||
h.rendering.destroy();
|
||||
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
||||
const malformed = () => new Error('Malformed cyclone snapshot');
|
||||
const text = (value, max = 160) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.trim() ||
|
||||
value.length > max ||
|
||||
/[\u0000-\u001f<>]/.test(value)
|
||||
)
|
||||
throw malformed();
|
||||
return value;
|
||||
};
|
||||
const number = (value, low, high) => {
|
||||
if (value === null) return null;
|
||||
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
|
||||
return value;
|
||||
};
|
||||
const time = (value) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
|
||||
!Number.isFinite(Date.parse(value)) ||
|
||||
new Date(value).toISOString() !== value
|
||||
)
|
||||
throw malformed();
|
||||
return value;
|
||||
};
|
||||
const advisory = (value) => {
|
||||
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
|
||||
throw malformed();
|
||||
return value;
|
||||
};
|
||||
const position = (value) => {
|
||||
if (!value || value.longitude === null || value.latitude === null)
|
||||
throw malformed();
|
||||
return {
|
||||
longitude: number(value.longitude, -180, 180),
|
||||
latitude: number(value.latitude, -90, 90),
|
||||
};
|
||||
};
|
||||
|
||||
function officialLink(value, outlook = false) {
|
||||
if (value === null) return null;
|
||||
const url = new URL(text(value, 256));
|
||||
if (
|
||||
url.origin !== 'https://www.nhc.noaa.gov' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash
|
||||
)
|
||||
throw malformed();
|
||||
if (
|
||||
outlook
|
||||
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
|
||||
url.pathname + url.search,
|
||||
)
|
||||
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
|
||||
)
|
||||
throw malformed();
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function geometry(value, kind, budget) {
|
||||
if (value === null) return null;
|
||||
const allowed =
|
||||
kind === 'track'
|
||||
? ['LineString', 'MultiLineString']
|
||||
: ['Polygon', 'MultiPolygon'];
|
||||
if (!allowed.includes(value?.type)) throw malformed();
|
||||
const point = (pair) => {
|
||||
if (
|
||||
!Array.isArray(pair) ||
|
||||
pair.length !== 2 ||
|
||||
pair.some((v) => typeof v !== 'number')
|
||||
)
|
||||
throw malformed();
|
||||
position({ longitude: pair[0], latitude: pair[1] });
|
||||
if (++budget.coordinates > 25_000) throw malformed();
|
||||
return [...pair];
|
||||
};
|
||||
const list = (items, read, min = 1, max = 128) => {
|
||||
if (!Array.isArray(items) || items.length < min || items.length > max)
|
||||
throw malformed();
|
||||
return items.map(read);
|
||||
};
|
||||
const line = (pairs) => list(pairs, point, 2, 10_000);
|
||||
const ring = (pairs) => {
|
||||
const result = list(pairs, point, 4, 10_000);
|
||||
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
|
||||
throw malformed();
|
||||
return result;
|
||||
};
|
||||
const polygon = (rings) => list(rings, ring);
|
||||
const coordinates =
|
||||
value.type === 'LineString'
|
||||
? line(value.coordinates)
|
||||
: value.type === 'MultiLineString'
|
||||
? list(value.coordinates, line)
|
||||
: value.type === 'Polygon'
|
||||
? polygon(value.coordinates)
|
||||
: list(value.coordinates, polygon);
|
||||
return { type: value.type, coordinates };
|
||||
}
|
||||
|
||||
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
|
||||
export function validateCycloneSnapshot(value) {
|
||||
if (
|
||||
!value ||
|
||||
value.schemaVersion !== 1 ||
|
||||
typeof value.stale !== 'boolean' ||
|
||||
typeof value.unavailable !== 'boolean' ||
|
||||
!Array.isArray(value.storms) ||
|
||||
value.storms.length > 32
|
||||
)
|
||||
throw malformed();
|
||||
const seen = new Set(),
|
||||
budget = { coordinates: 0, points: 0 };
|
||||
const storms = value.storms.map((raw) => {
|
||||
if (
|
||||
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
|
||||
seen.has(raw.id) ||
|
||||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
|
||||
)
|
||||
throw malformed();
|
||||
seen.add(raw.id);
|
||||
const advisoryNumber = advisory(raw.advisoryNumber);
|
||||
const geometryAdvisoryNumber =
|
||||
raw.geometryAdvisoryNumber === null
|
||||
? null
|
||||
: advisory(raw.geometryAdvisoryNumber);
|
||||
if (
|
||||
raw.geometryStatus === 'current' &&
|
||||
geometryAdvisoryNumber !== advisoryNumber
|
||||
)
|
||||
throw malformed();
|
||||
if (
|
||||
!Array.isArray(raw.forecastPoints) ||
|
||||
(budget.points += raw.forecastPoints.length) > 500
|
||||
)
|
||||
throw malformed();
|
||||
const taus = new Set();
|
||||
const forecastPoints = raw.forecastPoints.map((point) => {
|
||||
const tauHours = number(point.tauHours, 0, 168);
|
||||
if (tauHours === null || taus.has(tauHours)) throw malformed();
|
||||
taus.add(tauHours);
|
||||
if (++budget.coordinates > 25_000) throw malformed();
|
||||
return {
|
||||
position: position(point.position),
|
||||
tauHours,
|
||||
windKt: number(point.windKt, 0, 300),
|
||||
gustKt: number(point.gustKt, 0, 350),
|
||||
};
|
||||
});
|
||||
const track = geometry(raw.track, 'track', budget),
|
||||
cone = geometry(raw.cone, 'cone', budget);
|
||||
if (
|
||||
raw.geometryStatus !== 'current' &&
|
||||
(track || cone || forecastPoints.length)
|
||||
)
|
||||
throw malformed();
|
||||
if (
|
||||
raw.geometryStatus === 'current' &&
|
||||
(!track || !cone || !forecastPoints.length)
|
||||
)
|
||||
throw malformed();
|
||||
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
|
||||
return {
|
||||
id: raw.id,
|
||||
name: text(raw.name, 80),
|
||||
classification: text(raw.classification, 16),
|
||||
basin: raw.basin,
|
||||
position: position(raw.position),
|
||||
positionAt: time(raw.positionAt),
|
||||
advisoryNumber,
|
||||
issuedAt: time(raw.issuedAt),
|
||||
windKt: number(raw.windKt, 0, 300),
|
||||
pressureHpa: number(raw.pressureHpa, 800, 1100),
|
||||
movement: {
|
||||
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
|
||||
speedKt: number(raw.movement?.speedKt, 0, 200),
|
||||
},
|
||||
advisoryUrl: officialLink(raw.advisoryUrl),
|
||||
outlookUrl: officialLink(raw.outlookUrl, true),
|
||||
geometryStatus: raw.geometryStatus,
|
||||
geometryAdvisoryNumber,
|
||||
forecastPoints,
|
||||
track,
|
||||
cone,
|
||||
};
|
||||
});
|
||||
if (value.unavailable && storms.length) throw malformed();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: text(value.source),
|
||||
attribution: text(value.attribution, 240),
|
||||
coverage: text(value.coverage, 240),
|
||||
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
|
||||
stale: value.stale,
|
||||
unavailable: value.unavailable,
|
||||
reason: value.reason === null ? null : text(value.reason, 240),
|
||||
storms,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
|
||||
export function createCycloneSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Cyclone request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/cyclones', {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Cyclone HTTP ${response.status}`);
|
||||
}
|
||||
const result = await readResponseJsonCapped(
|
||||
response,
|
||||
CYCLONE_RESPONSE_LIMIT,
|
||||
controller.signal,
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
return validateCycloneSnapshot(result);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createCycloneSource,
|
||||
validateCycloneSnapshot,
|
||||
CYCLONE_RESPONSE_LIMIT,
|
||||
} from './source.js';
|
||||
|
||||
const issuedAt = '2026-09-16T03:00:00.000Z';
|
||||
function fixture() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'NOAA NHC / CPHC',
|
||||
attribution: 'NOAA/NWS',
|
||||
coverage: 'Atlantic and eastern/central North Pacific',
|
||||
fetchedAt: Date.parse(issuedAt),
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
reason: null,
|
||||
storms: [
|
||||
{
|
||||
id: 'ep152026',
|
||||
name: 'Fifteen-E',
|
||||
classification: 'PTC',
|
||||
basin: 'EP',
|
||||
position: { longitude: -125.8, latitude: 15.5 },
|
||||
positionAt: issuedAt,
|
||||
issuedAt,
|
||||
advisoryNumber: '10',
|
||||
windKt: 25,
|
||||
pressureHpa: 1006,
|
||||
movement: { directionDegrees: null, speedKt: null },
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
|
||||
geometryStatus: 'pending',
|
||||
geometryAdvisoryNumber: null,
|
||||
forecastPoints: [],
|
||||
track: null,
|
||||
cone: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function current() {
|
||||
const value = fixture(),
|
||||
storm = value.storms[0];
|
||||
storm.geometryStatus = 'current';
|
||||
storm.geometryAdvisoryNumber = '10';
|
||||
storm.forecastPoints = [
|
||||
{
|
||||
position: { longitude: 179, latitude: 15 },
|
||||
tauHours: 12,
|
||||
windKt: 30,
|
||||
gustKt: 40,
|
||||
},
|
||||
];
|
||||
storm.track = {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[179, 15],
|
||||
[-179, 16],
|
||||
],
|
||||
[
|
||||
[170, 14],
|
||||
[179, 15],
|
||||
],
|
||||
],
|
||||
};
|
||||
storm.cone = {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [
|
||||
[
|
||||
[
|
||||
[178, 10],
|
||||
[-178, 10],
|
||||
[-178, 20],
|
||||
[178, 10],
|
||||
],
|
||||
[
|
||||
[179, 12],
|
||||
[-179, 12],
|
||||
[-179, 14],
|
||||
[179, 12],
|
||||
],
|
||||
],
|
||||
[
|
||||
[
|
||||
[170, 1],
|
||||
[171, 1],
|
||||
[171, 2],
|
||||
[170, 1],
|
||||
],
|
||||
],
|
||||
],
|
||||
};
|
||||
return value;
|
||||
}
|
||||
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
|
||||
const value = current();
|
||||
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
|
||||
value.storms[0].cone.properties = {
|
||||
html: '<script>',
|
||||
href: 'https://example.invalid',
|
||||
};
|
||||
const result = validateCycloneSnapshot(value);
|
||||
assert.deepEqual(
|
||||
result.storms[0].cone.coordinates,
|
||||
value.storms[0].cone.coordinates,
|
||||
);
|
||||
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
|
||||
assert.equal(result.storms[0].cone.properties, undefined);
|
||||
assert.notEqual(
|
||||
result.storms[0].cone.coordinates,
|
||||
value.storms[0].cone.coordinates,
|
||||
);
|
||||
});
|
||||
test('successful empty coverage is distinct from unavailable', () => {
|
||||
const value = fixture();
|
||||
value.storms = [];
|
||||
assert.equal(validateCycloneSnapshot(value).unavailable, false);
|
||||
assert.equal(
|
||||
validateCycloneSnapshot({
|
||||
...value,
|
||||
unavailable: true,
|
||||
stale: true,
|
||||
fetchedAt: null,
|
||||
reason: 'Unavailable',
|
||||
}).unavailable,
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
|
||||
/Malformed/,
|
||||
);
|
||||
});
|
||||
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
|
||||
const changes = [
|
||||
(storm) => {
|
||||
storm.geometryAdvisoryNumber = '9';
|
||||
},
|
||||
(storm) => {
|
||||
storm.geometryStatus = 'pending';
|
||||
},
|
||||
(storm) => {
|
||||
storm.position.longitude = 181;
|
||||
},
|
||||
(storm) => {
|
||||
storm.track.coordinates[0][0][0] = NaN;
|
||||
},
|
||||
(storm) => {
|
||||
storm.cone.coordinates[0][0].pop();
|
||||
},
|
||||
(storm) => {
|
||||
storm.forecastPoints.push(storm.forecastPoints[0]);
|
||||
},
|
||||
(storm) => {
|
||||
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
|
||||
},
|
||||
(storm) => {
|
||||
storm.issuedAt = '2026-02-31T00:00:00.000Z';
|
||||
},
|
||||
(storm) => {
|
||||
storm.windKt = 9999;
|
||||
},
|
||||
];
|
||||
for (const change of changes) {
|
||||
const value = current();
|
||||
change(value.storms[0]);
|
||||
assert.throws(() => validateCycloneSnapshot(value));
|
||||
}
|
||||
const oversized = current();
|
||||
oversized.storms[0].track = {
|
||||
type: 'MultiLineString',
|
||||
coordinates: Array.from({ length: 3 }, () =>
|
||||
Array.from({ length: 9000 }, () => [1, 2]),
|
||||
),
|
||||
};
|
||||
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateCycloneSnapshot({
|
||||
...fixture(),
|
||||
storms: Array(33).fill(fixture().storms[0]),
|
||||
}),
|
||||
/Malformed/,
|
||||
);
|
||||
});
|
||||
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
|
||||
const calls = [];
|
||||
const source = createCycloneSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json(fixture());
|
||||
},
|
||||
});
|
||||
assert.equal(calls.length, 0);
|
||||
await source.getSnapshot();
|
||||
assert.equal(calls[0].url, '/api/cyclones');
|
||||
assert.equal(calls[0].options.redirect, 'error');
|
||||
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
const oversized = createCycloneSource({
|
||||
fetchImpl: async () =>
|
||||
new Response('{}', {
|
||||
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(oversized.getSnapshot(), /too large/);
|
||||
});
|
||||
test('source deadline cancels request and removes timer on completion', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let signal;
|
||||
const source = createCycloneSource({
|
||||
timeoutMs: 10,
|
||||
fetchImpl: async (_url, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((_resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const pending = source.getSnapshot();
|
||||
const rejected = assert.rejects(pending, /timed out/);
|
||||
t.mock.timers.tick(11);
|
||||
await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { createTesting } from './testing.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createRecords } from './records.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
@@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) {
|
||||
parts.catalog = createCatalog(context);
|
||||
parts.labels = createLabels(context);
|
||||
parts.orbits = createOrbits(context);
|
||||
parts.records = createRecords(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.tracking = createTracking(context);
|
||||
parts.testing = createTesting(context);
|
||||
@@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) {
|
||||
applySatellitePointFocusDeemphasis:
|
||||
parts.rendering.applySatellitePointFocusDeemphasis,
|
||||
getNextIssPass: parts.orbits.getNextIssPass,
|
||||
getNextSatellitePass: parts.orbits.getNextSatellitePass,
|
||||
resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass,
|
||||
getAnalystRecords: parts.records.getAnalystRecords,
|
||||
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
|
||||
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
|
||||
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
degreesLat,
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextIssPass } from '../../data/issPass.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -136,22 +136,98 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Next ISS pass for an observer. Requires the catalog to have loaded (the
|
||||
* satellites layer enabled at least once this session).
|
||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
|
||||
* @param {Object} options
|
||||
* @param {number} options.latDeg Observer latitude
|
||||
* @param {number} options.lonDeg Observer longitude
|
||||
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
|
||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}}
|
||||
*/
|
||||
|
||||
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
|
||||
const sat = layerState._catalog.get(ISS_NORAD);
|
||||
function getNextIssPass({
|
||||
latDeg,
|
||||
lonDeg,
|
||||
minElevDeg = 10,
|
||||
requireVisible = false,
|
||||
fromMs = Date.now(),
|
||||
}) {
|
||||
return getNextSatellitePass(ISS_NORAD, {
|
||||
latDeg,
|
||||
lonDeg,
|
||||
minElevDeg,
|
||||
requireVisible,
|
||||
fromMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next pass of any catalog satellite for an observer.
|
||||
* @param {number} noradId NORAD satellite catalog ID
|
||||
* @param {Object} options
|
||||
* @param {number} options.latDeg Observer latitude
|
||||
* @param {number} options.lonDeg Observer longitude
|
||||
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
|
||||
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
|
||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
|
||||
*/
|
||||
|
||||
function getNextSatellitePass(
|
||||
noradId,
|
||||
{
|
||||
latDeg,
|
||||
lonDeg,
|
||||
minElevDeg = 10,
|
||||
requireVisible = false,
|
||||
fromMs = Date.now(),
|
||||
},
|
||||
) {
|
||||
const id = Number(noradId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return { status: 'no-tle' };
|
||||
const sat = layerState._catalog.get(id);
|
||||
if (!sat || !sat.satrec) return { status: 'no-tle' };
|
||||
const pass = findNextIssPass({
|
||||
const pass = findNextSatellitePass({
|
||||
satrec: sat.satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs: Date.now(),
|
||||
fromMs,
|
||||
minElevDeg,
|
||||
requireVisible,
|
||||
});
|
||||
return pass ? { status: 'ok', pass } : { status: 'none' };
|
||||
}
|
||||
|
||||
/** Resolve only loaded catalog identities; ambiguous names never pick a first row. */
|
||||
function resolveSatelliteForPass(query) {
|
||||
const text = String(query ?? '').trim();
|
||||
if (!text || text.length > 120) return { status: 'not-found' };
|
||||
const exactId = /^\d+$/.test(text) ? Number(text) : null;
|
||||
if (exactId !== null) {
|
||||
const sat = layerState._catalog.get(exactId);
|
||||
return sat?.satrec
|
||||
? { status: 'ok', noradId: exactId, name: sat.name }
|
||||
: { status: 'not-found' };
|
||||
}
|
||||
const q = text.toLowerCase();
|
||||
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
|
||||
const exact = rows.filter(
|
||||
([, sat]) => String(sat.name).toLowerCase() === q,
|
||||
);
|
||||
const matches = exact.length
|
||||
? exact
|
||||
: rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
|
||||
if (!matches.length) return { status: 'not-found' };
|
||||
if (matches.length > 1)
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
candidates: matches
|
||||
.slice(0, 8)
|
||||
.map(([noradId, sat]) => ({ noradId, name: sat.name })),
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a mission-to-catalog name match. Compact identifier containment handles
|
||||
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
|
||||
@@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
orbitalPeriodSeconds,
|
||||
computeOrbitPath,
|
||||
getNextIssPass,
|
||||
getNextSatellitePass,
|
||||
resolveSatelliteForPass,
|
||||
scoreSatelliteNameMatch,
|
||||
internationalDesignatorYear,
|
||||
lookupTleEntries,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { ISS_NORAD } from './policy.js';
|
||||
|
||||
/**
|
||||
* Map one CelesTrak catalog satellite to a JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. `id` is the display name (same
|
||||
* convention as vessels); `noradId` is the track_entity key.
|
||||
* @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}.
|
||||
* @returns {{id: string, noradId: string|null, name: string|null,
|
||||
* lat: number|null, lon: number|null, altitudeM: number|null,
|
||||
* speedMps: number|null, satelliteClass: string|null, group: string|null}}
|
||||
*/
|
||||
export function mapAnalystRecord(raw) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
const noradNum =
|
||||
raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
|
||||
const noradId = Number.isFinite(noradNum)
|
||||
? String(Math.trunc(noradNum))
|
||||
: text(raw?.noradId);
|
||||
const name = text(raw?.name);
|
||||
const group = text(raw?.group);
|
||||
const isIss = noradNum === ISS_NORAD;
|
||||
return {
|
||||
id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'),
|
||||
noradId,
|
||||
name,
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
altitudeM: num(raw?.altitudeM),
|
||||
speedMps: num(raw?.speedMps),
|
||||
// Empty records stay class-less; the class table's fallback is VISUAL and
|
||||
// must not leak onto a row that has no satellite identity.
|
||||
satelliteClass:
|
||||
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create on-demand analyst snapshots owned by one layer instance. */
|
||||
export function createRecords({ state, parts }) {
|
||||
function analystGeo(noradId, sat, now) {
|
||||
const pos = sat?.satrec
|
||||
? parts.orbits.propagatePosition(sat.satrec, now)
|
||||
: null;
|
||||
if (
|
||||
pos &&
|
||||
Number.isFinite(pos.latitude) &&
|
||||
Number.isFinite(pos.longitude)
|
||||
) {
|
||||
return {
|
||||
lat: pos.latitude,
|
||||
lon: pos.longitude,
|
||||
altitudeM: pos.altitude,
|
||||
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
|
||||
};
|
||||
}
|
||||
const point = state._points.get(noradId);
|
||||
if (!point?.position) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(point.position);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
altitudeM: carto.height,
|
||||
speedMps: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getAnalystRecords(maxCount = 2000) {
|
||||
if (!state._enabled || !state._catalog.size) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const now = new Date();
|
||||
const result = [];
|
||||
const emit = (noradId, sat) => {
|
||||
if (result.length >= limit) return false;
|
||||
const pos = analystGeo(noradId, sat, now);
|
||||
if (!pos) return true;
|
||||
result.push(
|
||||
mapAnalystRecord({
|
||||
noradId,
|
||||
name: sat?.name,
|
||||
group: sat?.group,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
altitudeM: pos.altitudeM,
|
||||
speedMps: pos.speedMps,
|
||||
}),
|
||||
);
|
||||
return result.length < limit;
|
||||
};
|
||||
for (const [noradId, sat] of state._catalog) {
|
||||
if (sat?.group === 'dense') continue;
|
||||
if (!emit(noradId, sat)) break;
|
||||
}
|
||||
if (result.length < limit) {
|
||||
for (const [noradId, sat] of state._catalog) {
|
||||
if (sat?.group !== 'dense') continue;
|
||||
if (!emit(noradId, sat)) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return { getAnalystRecords };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { createSatelliteSource } from './source.js';
|
||||
import { createSatellitesLayer } from './index.js';
|
||||
|
||||
@@ -56,3 +57,110 @@ test('satellite factories keep control state separate and construct without requ
|
||||
assert.equal(second.getParams().showPoints, true);
|
||||
assert.notEqual(first.getStats(), second.getStats());
|
||||
});
|
||||
|
||||
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
|
||||
const source = { readGroup() {} };
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'picking',
|
||||
'focus',
|
||||
'readout',
|
||||
'overlays',
|
||||
'context',
|
||||
'render',
|
||||
'layerState',
|
||||
].map((key) => [key, {}]),
|
||||
);
|
||||
services.layerState.isExplicitLayerStateOrigin = () => false;
|
||||
const layer = createSatellitesLayer({ source, services });
|
||||
|
||||
// Cold catalog (no satellites loaded yet)
|
||||
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
|
||||
status: 'no-tle',
|
||||
});
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// String NORAD ID coercion
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// Invalid / non-finite ID handling
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// Populated catalog: verify numeric and string coercion succeed
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
layer._setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
});
|
||||
|
||||
const FROM_MS = 1221868800000;
|
||||
const numericResult = layer.getNextSatellitePass(25544, {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(numericResult.status, 'ok');
|
||||
assert.ok(numericResult.pass);
|
||||
|
||||
const stringResult = layer.getNextSatellitePass('25544', {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(stringResult.status, 'ok');
|
||||
assert.deepEqual(stringResult, numericResult);
|
||||
|
||||
const issResult = layer.getNextIssPass({
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(issResult.status, 'ok');
|
||||
assert.deepEqual(issResult, numericResult);
|
||||
|
||||
layer._setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
neighbours: [
|
||||
{ noradId: 10, name: 'STARLINK ONE', satrec },
|
||||
{ noradId: 11, name: 'STARLINK TWO', satrec },
|
||||
],
|
||||
});
|
||||
assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544);
|
||||
assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544);
|
||||
assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous');
|
||||
assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2);
|
||||
assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10);
|
||||
assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found');
|
||||
layer._clearDenseCatalogStateForTest();
|
||||
assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found');
|
||||
|
||||
// Non-existent satellite ID in populated catalog
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(99999, {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
}),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
});
|
||||
@@ -54,7 +54,14 @@ export function createSelection({
|
||||
|
||||
// A sibling layer already owns this click. Preserve the current vessel
|
||||
// selection and do not compete with its camera command.
|
||||
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
|
||||
const siblingOwned =
|
||||
pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
|
||||
// Cyclone geometry is ambient context behind painted cards. Its handler
|
||||
// yields to the same foreground AIS hit. Keep moving-contact precedence
|
||||
// unchanged: those handlers may issue their own camera command.
|
||||
const cycloneBackground =
|
||||
siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
|
||||
if (siblingOwned && !cycloneBackground) return;
|
||||
|
||||
// Cards are painted on a pointer-events:none canvas, so the scene pick is
|
||||
// usually terrain behind the card. Resolve against the host's current
|
||||
@@ -64,10 +71,14 @@ export function createSelection({
|
||||
click.position?.x,
|
||||
click.position?.y,
|
||||
{
|
||||
sourceId: VESSEL_OVERLAY_SOURCE_ID,
|
||||
...(cycloneBackground
|
||||
? {}
|
||||
: { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
|
||||
},
|
||||
)
|
||||
: null;
|
||||
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
|
||||
return;
|
||||
if (!record && cardHit) {
|
||||
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
|
||||
? cardHit.entryId.slice('vessel:'.length)
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
||||
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
||||
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
|
||||
|
||||
/** Transient observed time. Products must honor the signal before installing a frame. */
|
||||
export function createWeatherClock({
|
||||
now = Date.now,
|
||||
setTimeout = globalThis.setTimeout,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
} = {}) {
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
let mode = 'latest';
|
||||
let target = null;
|
||||
let playing = false;
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
let destroyed = false;
|
||||
const timesFor = (product) =>
|
||||
[...new Set(product.getTimes())]
|
||||
.filter((time) => Number.isFinite(Date.parse(time)))
|
||||
.sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
const getTimeline = () =>
|
||||
[
|
||||
...new Set(
|
||||
[...registry.values()].flatMap(({ product }) =>
|
||||
product.isSuspended() ? [] : timesFor(product),
|
||||
),
|
||||
),
|
||||
].sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
function selectFor(id, time) {
|
||||
const product = registry.get(id)?.product;
|
||||
if (!product) return null;
|
||||
const at = Date.parse(time);
|
||||
return (
|
||||
timesFor(product).findLast((candidate) => {
|
||||
const age = at - Date.parse(candidate);
|
||||
return age >= 0 && age <= product.maxGapMs;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
const selectedFor = (product) =>
|
||||
mode === 'latest'
|
||||
? (timesFor(product).at(-1) ?? null)
|
||||
: selectFor(product.id, target);
|
||||
const getState = () => ({
|
||||
mode,
|
||||
target,
|
||||
playing,
|
||||
timeline: getTimeline(),
|
||||
products: [...registry.values()].map(({ product }) => ({
|
||||
id: product.id,
|
||||
shown: product.getShownTime() ?? null,
|
||||
selected: selectedFor(product),
|
||||
suspended: Boolean(product.isSuspended()),
|
||||
})),
|
||||
});
|
||||
const notify = () => {
|
||||
if (!destroyed) for (const listener of listeners) listener(getState());
|
||||
};
|
||||
const cancelTimer = () => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const canPlay = () => getTimeline().length >= 2;
|
||||
function schedule() {
|
||||
cancelTimer();
|
||||
if (!playing || destroyed || pending) return;
|
||||
if (!canPlay()) {
|
||||
playing = false;
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const owner = generation;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
if (destroyed || !playing || owner !== generation) return;
|
||||
const timeline = getTimeline();
|
||||
if (timeline.length < 2) {
|
||||
api.pause();
|
||||
return;
|
||||
}
|
||||
const next = timeline.find(
|
||||
(time) => Date.parse(time) > Date.parse(target),
|
||||
);
|
||||
void api.setTarget(next ?? timeline[0]);
|
||||
}, 2000);
|
||||
}
|
||||
function transition() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
cancelTimer();
|
||||
const owner = ++generation;
|
||||
if (!canPlay()) playing = false;
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
// Start all products together; a rejection must not skip the settle barrier.
|
||||
const tasks = [...registry.values()].map((entry) => {
|
||||
entry.controller = new AbortController();
|
||||
const { signal } = entry.controller;
|
||||
const time = selectedFor(entry.product);
|
||||
return Promise.resolve().then(() => {
|
||||
if (signal.aborted || owner !== generation || destroyed) return false;
|
||||
return entry.product.apply(time, { signal });
|
||||
});
|
||||
});
|
||||
const result = Promise.allSettled(tasks).then(() => {
|
||||
if (destroyed || owner !== generation) return false;
|
||||
pending = null;
|
||||
if (!canPlay()) playing = false;
|
||||
notify();
|
||||
schedule();
|
||||
return true;
|
||||
});
|
||||
pending = result;
|
||||
notify();
|
||||
return result;
|
||||
}
|
||||
const api = {
|
||||
register(product) {
|
||||
if (destroyed) return () => {};
|
||||
if (registry.has(product.id))
|
||||
throw new Error(`Duplicate weather product: ${product.id}`);
|
||||
const entry = { product, controller: null };
|
||||
registry.set(product.id, entry);
|
||||
void api.refresh();
|
||||
return () => {
|
||||
if (registry.get(product.id) !== entry) return;
|
||||
entry.controller?.abort();
|
||||
registry.delete(product.id);
|
||||
void api.refresh();
|
||||
};
|
||||
},
|
||||
getTimeline,
|
||||
selectFor,
|
||||
setTarget(time) {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
if (!Number.isFinite(Date.parse(time)))
|
||||
throw new TypeError('Weather target must be a UTC time');
|
||||
const normalized = new Date(time).toISOString();
|
||||
if (mode === 'history' && target === normalized)
|
||||
return pending ?? Promise.resolve(true);
|
||||
mode = 'history';
|
||||
target = normalized;
|
||||
return transition();
|
||||
},
|
||||
step(direction) {
|
||||
if (destroyed || ![-1, 1].includes(direction))
|
||||
return Promise.resolve(false);
|
||||
api.pause();
|
||||
const timeline = getTimeline();
|
||||
if (!timeline.length) return Promise.resolve(false);
|
||||
const at = Date.parse(target ?? timeline.at(-1));
|
||||
const next =
|
||||
direction < 0
|
||||
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
|
||||
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
|
||||
return api.setTarget(next);
|
||||
},
|
||||
latest() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
mode = 'latest';
|
||||
target = null;
|
||||
playing = false;
|
||||
return transition();
|
||||
},
|
||||
play() {
|
||||
if (destroyed || playing || !canPlay())
|
||||
return pending ?? Promise.resolve(false);
|
||||
playing = true;
|
||||
if (mode === 'latest') {
|
||||
const timeline = getTimeline();
|
||||
return api.setTarget(
|
||||
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
|
||||
);
|
||||
}
|
||||
notify();
|
||||
schedule();
|
||||
return pending ?? Promise.resolve(true);
|
||||
},
|
||||
pause() {
|
||||
cancelTimer();
|
||||
if (!playing) return;
|
||||
playing = false;
|
||||
notify();
|
||||
},
|
||||
togglePlay() {
|
||||
return playing ? api.pause() : api.play();
|
||||
},
|
||||
refresh() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
if (!canPlay()) {
|
||||
playing = false;
|
||||
cancelTimer();
|
||||
}
|
||||
if (mode === 'history') return transition();
|
||||
notify();
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
getState,
|
||||
subscribe(listener) {
|
||||
if (!destroyed) listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
++generation;
|
||||
playing = false;
|
||||
cancelTimer();
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
registry.clear();
|
||||
listeners.clear();
|
||||
pending = null;
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWeatherClock,
|
||||
RADAR_MAX_GAP_MS,
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
|
||||
const time = (minutes) =>
|
||||
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
function product(id, minutes, maxGapMs = RADAR_MAX_GAP_MS) {
|
||||
const p = {
|
||||
id,
|
||||
maxGapMs,
|
||||
times: minutes.map(time),
|
||||
shown: null,
|
||||
suspended: false,
|
||||
calls: [],
|
||||
getTimes: () => p.times,
|
||||
getShownTime: () => p.shown,
|
||||
isSuspended: () => p.suspended,
|
||||
async apply(selected, { signal }) {
|
||||
p.calls.push({ selected, signal });
|
||||
if (signal.aborted) return false;
|
||||
p.shown = selected;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return p;
|
||||
}
|
||||
function harness(t) {
|
||||
let at = Date.parse(time(20));
|
||||
const timers = new Map();
|
||||
let id = 0;
|
||||
const clock = createWeatherClock({
|
||||
now: () => at,
|
||||
setTimeout(fn, delay) {
|
||||
timers.set(++id, { fn, due: at + delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
});
|
||||
t.after(() => clock.destroy());
|
||||
return {
|
||||
clock,
|
||||
timers,
|
||||
tick(ms) {
|
||||
at += ms;
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.due > at) continue;
|
||||
timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('union excludes suspended products; nearest selection never uses future or out-of-gap frames', (t) => {
|
||||
const { clock } = harness(t);
|
||||
const radar = product('radar', [10, 0, 10]);
|
||||
const regional = product('regional', [5, 10]);
|
||||
const global = product('global', [-180, -60], GLOBAL_INFRARED_MAX_GAP_MS);
|
||||
const hidden = product('hidden', [99]);
|
||||
hidden.suspended = true;
|
||||
for (const p of [radar, regional, global, hidden]) clock.register(p);
|
||||
assert.deepEqual(clock.getTimeline(), [-180, -60, 0, 5, 10].map(time));
|
||||
assert.equal(clock.selectFor('radar', time(9)), time(0));
|
||||
assert.equal(clock.selectFor('radar', time(-1)), null);
|
||||
assert.equal(clock.selectFor('radar', time(40)), time(10));
|
||||
assert.equal(clock.selectFor('radar', time(41)), null);
|
||||
assert.equal(clock.selectFor('global', time(120)), time(-60));
|
||||
assert.equal(clock.selectFor('global', time(121)), null);
|
||||
assert.equal(clock.selectFor('missing', time(10)), null);
|
||||
assert.equal(REGIONAL_INFRARED_MAX_GAP_MS, 30 * 60_000);
|
||||
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
|
||||
});
|
||||
|
||||
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
const satellite = product('satellite', [5, 15]);
|
||||
const lightning = product('lightning', [15]);
|
||||
for (const p of [radar, satellite, lightning]) clock.register(p);
|
||||
await clock.setTarget(time(10));
|
||||
assert.deepEqual(
|
||||
[radar.shown, satellite.shown, lightning.shown],
|
||||
[time(10), time(5), null],
|
||||
);
|
||||
assert.deepEqual(
|
||||
clock.getState().products.map(({ selected }) => selected),
|
||||
[time(10), time(5), null],
|
||||
);
|
||||
await clock.latest();
|
||||
assert.deepEqual(
|
||||
[radar.shown, satellite.shown, lightning.shown],
|
||||
[time(20), time(15), time(15)],
|
||||
);
|
||||
assert.equal(clock.getState().mode, 'latest');
|
||||
assert.equal(clock.getState().target, null);
|
||||
assert.equal(clock.getState().playing, false);
|
||||
});
|
||||
|
||||
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
|
||||
const { clock, timers } = harness(t);
|
||||
const p = product('radar', [0, 10, 20]);
|
||||
const loads = [];
|
||||
p.apply = (selected, { signal }) =>
|
||||
new Promise((resolve) => {
|
||||
loads.push({
|
||||
selected,
|
||||
signal,
|
||||
finish() {
|
||||
if (!signal.aborted) p.shown = selected;
|
||||
resolve(!signal.aborted);
|
||||
},
|
||||
});
|
||||
});
|
||||
clock.register(p);
|
||||
let notifications = 0;
|
||||
clock.subscribe(() => notifications++);
|
||||
const old = clock.setTarget(time(0));
|
||||
await flush();
|
||||
const current = clock.setTarget(time(10));
|
||||
await flush();
|
||||
assert.equal(loads[0].signal.aborted, true);
|
||||
loads[1].finish();
|
||||
assert.equal(await current, true);
|
||||
const count = notifications;
|
||||
loads[0].finish();
|
||||
assert.equal(await old, false);
|
||||
assert.equal(notifications, count);
|
||||
assert.equal(p.shown, time(10));
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
clock.register(product('radar', [0, 10, 20]));
|
||||
clock.register(product('satellite', [5, 15]));
|
||||
await clock.step(-1);
|
||||
assert.equal(clock.getState().target, time(15));
|
||||
await clock.setTarget(time(12));
|
||||
await clock.step(-1);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
await clock.setTarget(time(12));
|
||||
await clock.step(1);
|
||||
assert.equal(clock.getState().target, time(15));
|
||||
await clock.setTarget(time(0));
|
||||
await clock.step(-1);
|
||||
assert.equal(clock.getState().target, time(0));
|
||||
await clock.setTarget(time(20));
|
||||
await clock.step(1);
|
||||
assert.equal(clock.getState().target, time(20));
|
||||
});
|
||||
|
||||
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
|
||||
const { clock, tick, timers } = harness(t);
|
||||
const a = product('radar', [0, 10]);
|
||||
const b = product('satellite', [5]);
|
||||
const loads = [];
|
||||
b.apply = () =>
|
||||
new Promise((resolve, reject) => loads.push({ resolve, reject }));
|
||||
clock.register(a);
|
||||
clock.register(b);
|
||||
const start = clock.play();
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
tick(10_000);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(a.calls.length, 1);
|
||||
loads[0].reject(new Error('unavailable'));
|
||||
await start;
|
||||
assert.equal(timers.size, 1);
|
||||
tick(1999);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
tick(1);
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(0));
|
||||
assert.equal(timers.size, 0);
|
||||
tick(10_000);
|
||||
assert.equal(clock.getState().target, time(0));
|
||||
loads[1].resolve(true);
|
||||
await flush();
|
||||
tick(2000);
|
||||
await flush();
|
||||
assert.equal(clock.getState().target, time(5));
|
||||
clock.pause();
|
||||
loads[2].resolve(false);
|
||||
await flush();
|
||||
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
|
||||
});
|
||||
|
||||
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
|
||||
const { clock, tick, timers } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
clock.register(p);
|
||||
await clock.play();
|
||||
await clock.play();
|
||||
assert.equal(timers.size, 1);
|
||||
clock.pause();
|
||||
tick(2000);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
assert.equal(timers.size, 0);
|
||||
await clock.togglePlay();
|
||||
p.times = [time(0)];
|
||||
await clock.refresh();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
await clock.play();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
p.times = [time(0), time(10)];
|
||||
await clock.refresh();
|
||||
await clock.play();
|
||||
p.suspended = true;
|
||||
await clock.refresh();
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.deepEqual(clock.getTimeline(), []);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
|
||||
const { clock } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
clock.register(p);
|
||||
await clock.refresh();
|
||||
assert.equal(p.calls.length, 0);
|
||||
await clock.setTarget(time(10));
|
||||
p.times = [time(0), time(20)];
|
||||
await clock.refresh();
|
||||
assert.equal(p.shown, time(0));
|
||||
p.times = [time(20)];
|
||||
await clock.refresh();
|
||||
assert.equal(p.shown, null);
|
||||
assert.equal(clock.getState().target, time(10));
|
||||
});
|
||||
|
||||
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
|
||||
const { clock, timers, tick } = harness(t);
|
||||
const p = product('radar', [0, 10]);
|
||||
const unregister = clock.register(p);
|
||||
await clock.setTarget(time(0));
|
||||
const b = product('satellite', [0, 5]);
|
||||
clock.register(b);
|
||||
await flush();
|
||||
assert.equal(b.shown, time(0));
|
||||
await clock.play();
|
||||
unregister();
|
||||
unregister();
|
||||
await flush();
|
||||
assert.equal(p.calls.at(-1).signal.aborted, true);
|
||||
assert.deepEqual(
|
||||
clock.getState().products.map(({ id }) => id),
|
||||
['satellite'],
|
||||
);
|
||||
let notifications = 0;
|
||||
const off = clock.subscribe(() => notifications++);
|
||||
clock.pause();
|
||||
off();
|
||||
const before = notifications;
|
||||
await clock.play();
|
||||
assert.equal(notifications, before);
|
||||
clock.destroy();
|
||||
clock.destroy();
|
||||
tick(2000);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(b.calls.at(-1).signal.aborted, true);
|
||||
assert.equal(clock.getState().playing, false);
|
||||
assert.deepEqual(clock.getTimeline(), []);
|
||||
assert.equal(await clock.setTarget(time(5)), false);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Resolve the visible surface that can own weather imagery without scene mutation. */
|
||||
export function resolveImageryHost({ viewer, tileset }) {
|
||||
if (viewer?.scene?.globe?.show === true)
|
||||
return { collection: viewer.imageryLayers, kind: 'globe' };
|
||||
if (tileset?.imageryLayers)
|
||||
return { collection: tileset.imageryLayers, kind: 'tileset' };
|
||||
return { collection: null, kind: 'none' };
|
||||
}
|
||||
|
||||
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
|
||||
|
||||
/** Report imagery suspension without changing or detaching the resolved host. */
|
||||
export function imageryHostStatus(host) {
|
||||
return host.kind === 'none' ? NO_IMAGERY_HOST : null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
resolveImageryHost,
|
||||
imageryHostStatus,
|
||||
NO_IMAGERY_HOST,
|
||||
} from './imageryHost.js';
|
||||
|
||||
test('only a missing host suspends weather; 3D Tiles has no height gate', () => {
|
||||
assert.equal(imageryHostStatus({ kind: 'tileset' }), null);
|
||||
assert.equal(imageryHostStatus({ kind: 'globe' }), null);
|
||||
assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST);
|
||||
});
|
||||
|
||||
test('visible globe takes precedence over a tileset', () => {
|
||||
const viewer = { imageryLayers: {}, scene: { globe: { show: true } } };
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }),
|
||||
{ collection: viewer.imageryLayers, kind: 'globe' },
|
||||
);
|
||||
});
|
||||
test('hidden globe uses the supplied tileset imagery collection', () => {
|
||||
const tileset = { imageryLayers: {} };
|
||||
const viewer = { scene: { globe: { show: false } } };
|
||||
assert.deepEqual(resolveImageryHost({ viewer, tileset }), {
|
||||
collection: tileset.imageryLayers,
|
||||
kind: 'tileset',
|
||||
});
|
||||
});
|
||||
test('hidden globe without an imagery-capable tileset has no host', () => {
|
||||
for (const tileset of [null, {}])
|
||||
assert.deepEqual(
|
||||
resolveImageryHost({
|
||||
viewer: { scene: { globe: { show: false } } },
|
||||
tileset,
|
||||
}),
|
||||
{ collection: null, kind: 'none' },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
const priorities = new WeakMap();
|
||||
|
||||
/** Scalar context, infrared, radar, then lightning: independent of enable order. */
|
||||
export function orderWeatherImagery(collection, layer, priority) {
|
||||
priorities.set(layer, priority);
|
||||
if (typeof collection.raiseToTop !== 'function') return;
|
||||
const weather = [];
|
||||
for (let i = 0; i < collection.length; i++) {
|
||||
const item = collection.get(i);
|
||||
if (priorities.has(item)) weather.push(item);
|
||||
}
|
||||
const ordered = [...weather].sort(
|
||||
(a, b) => priorities.get(a) - priorities.get(b),
|
||||
);
|
||||
if (weather.every((item, i) => item === ordered[i])) return;
|
||||
for (const item of ordered) collection.raiseToTop(item);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { orderWeatherImagery } from './imageryOrder.js';
|
||||
|
||||
test('weather compositing remains consistent when scalar, observation and history arrive out of order', () => {
|
||||
const base = { name: 'basemap' },
|
||||
radar = { name: 'radar' },
|
||||
cloud = { name: 'cloud' },
|
||||
wind = { name: 'wind' },
|
||||
lightning = { name: 'lightning' };
|
||||
const items = [base];
|
||||
const collection = {
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
get: (i) => items[i],
|
||||
raiseToTop(layer) {
|
||||
items.push(...items.splice(items.indexOf(layer), 1));
|
||||
},
|
||||
};
|
||||
for (const [layer, priority] of [
|
||||
[radar, 2],
|
||||
[cloud, 1],
|
||||
[lightning, 3],
|
||||
[wind, 0],
|
||||
]) {
|
||||
items.push(layer);
|
||||
orderWeatherImagery(collection, layer, priority);
|
||||
}
|
||||
assert.deepEqual(items, [base, wind, cloud, radar, lightning]);
|
||||
const next = { name: 'new radar history frame' };
|
||||
items.push(next);
|
||||
orderWeatherImagery(collection, next, 2);
|
||||
assert.deepEqual(items, [base, wind, cloud, radar, next, lightning]);
|
||||
});
|
||||
|
||||
test('tileset imagery collection preserves scalar, infrared, radar and lightning order', async () => {
|
||||
const { ImageryLayerCollection, ImageryLayer } = await import('cesium');
|
||||
const tileset = { imageryLayers: new ImageryLayerCollection() };
|
||||
const layers = Array.from({ length: 4 }, () => new ImageryLayer());
|
||||
for (const priority of [3, 2, 0, 1]) {
|
||||
tileset.imageryLayers.add(layers[priority]);
|
||||
orderWeatherImagery(tileset.imageryLayers, layers[priority], priority);
|
||||
}
|
||||
assert.deepEqual(
|
||||
layers.map((_, i) => tileset.imageryLayers.get(i)),
|
||||
layers,
|
||||
);
|
||||
tileset.imageryLayers.destroy();
|
||||
});
|
||||
|
||||
test('already ordered imagery adds and repeated ordering cause no collection churn', () => {
|
||||
const items = [];
|
||||
let raises = 0;
|
||||
const collection = {
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
get: (i) => items[i],
|
||||
raiseToTop(layer) {
|
||||
raises++;
|
||||
items.push(...items.splice(items.indexOf(layer), 1));
|
||||
},
|
||||
};
|
||||
for (const priority of [0, 1, 1, 2, 3]) {
|
||||
const layer = {};
|
||||
items.push(layer);
|
||||
orderWeatherImagery(collection, layer, priority);
|
||||
orderWeatherImagery(collection, layer, priority);
|
||||
}
|
||||
assert.equal(raises, 0);
|
||||
});
|
||||
@@ -0,0 +1,727 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import {
|
||||
RADAR_MAX_GAP_MS,
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
} from './clock.js';
|
||||
|
||||
const STOPS = [
|
||||
[10, '#00ecec'],
|
||||
[20, '#0000f6'],
|
||||
[30, '#00c800'],
|
||||
[40, '#ffff00'],
|
||||
[50, '#ff9000'],
|
||||
[60, '#dc0000'],
|
||||
[70, '#ff00ff'],
|
||||
[80, '#05ede0'],
|
||||
];
|
||||
const LIGHTNING_STOPS = [
|
||||
['0.1', '#FFFFCC'],
|
||||
['1', '#FFA400'],
|
||||
['5', '#FF4500'],
|
||||
['10', '#FF0000'],
|
||||
['50', '#FF00FF'],
|
||||
['100', '#4000C0'],
|
||||
['200', '#00C7FF'],
|
||||
['300+', '#00FF00'],
|
||||
];
|
||||
const utc = (value) =>
|
||||
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
|
||||
|
||||
/** A per-application observation layer, using the existing layer lifecycle and
|
||||
* row controls. History is transient: shared links always open latest imagery. */
|
||||
export function createWeatherLayer({
|
||||
feed,
|
||||
clock,
|
||||
id = 'weather-radar',
|
||||
cesium = Cesium,
|
||||
createRendering = createWeatherRendering,
|
||||
documentRef = globalThis.document,
|
||||
eventTarget = globalThis.window,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Weather requires a snapshot source');
|
||||
const radar = id === 'weather-radar';
|
||||
const lightning = id === 'weather-lightning';
|
||||
const satellite = !radar && !lightning;
|
||||
let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional';
|
||||
let opacity = 'strong';
|
||||
let infrared = 'filtered';
|
||||
let viewer = null,
|
||||
rendering = null,
|
||||
manifest = null,
|
||||
request = null,
|
||||
listener = null;
|
||||
let enabled = false,
|
||||
loading = false,
|
||||
playing = false,
|
||||
followLatest = true;
|
||||
let generation = 0,
|
||||
timer = null,
|
||||
error = null,
|
||||
removeCamera = null;
|
||||
let motion = null;
|
||||
let runNavigation = null;
|
||||
let imageryHost = null;
|
||||
let hostCollection;
|
||||
let hostHidden = false;
|
||||
let hostStatus = null;
|
||||
let unregisterClock = null;
|
||||
let frameRequest = null;
|
||||
let noFrame = false;
|
||||
const maxGap = () =>
|
||||
radar
|
||||
? RADAR_MAX_GAP_MS
|
||||
: lightning
|
||||
? LIGHTNING_MAX_GAP_MS
|
||||
: product === 'clouds'
|
||||
? GLOBAL_INFRARED_MAX_GAP_MS
|
||||
: REGIONAL_INFRARED_MAX_GAP_MS;
|
||||
const isLatest = () =>
|
||||
clock ? clock.getState().mode === 'latest' : followLatest;
|
||||
const getHost = () =>
|
||||
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
|
||||
const notify = () => listener?.();
|
||||
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
|
||||
const unsubscribeClock = clock?.subscribe(() => {
|
||||
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
|
||||
notify();
|
||||
});
|
||||
const observationDelayed = () =>
|
||||
manifest?.latest &&
|
||||
Date.now() - Date.parse(manifest.latest) >
|
||||
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
|
||||
const stop = () => {
|
||||
if (clock) return;
|
||||
playing = false;
|
||||
rendering?.cancelPrefetch?.();
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
const suspended = () => hostHidden || documentRef?.hidden || motion?.matches;
|
||||
const onVisibility = () => {
|
||||
if (suspended()) rendering?.cancelPrefetch?.();
|
||||
if (clock) void clock.refresh();
|
||||
else if (suspended()) stop();
|
||||
notify();
|
||||
};
|
||||
|
||||
function checkHost(resume = true) {
|
||||
const host = getHost();
|
||||
const status = imageryHostStatus(host);
|
||||
const changed = host.collection !== hostCollection || hostStatus !== status;
|
||||
hostCollection = host.collection;
|
||||
hostStatus = status;
|
||||
hostHidden = status !== null;
|
||||
// Keep playback intent and the displayed time while the host is unavailable.
|
||||
if (!changed || !rendering) return;
|
||||
++generation;
|
||||
rendering.rehome?.();
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
loading = Boolean(request && !request.signal.aborted);
|
||||
if (clock && enabled) {
|
||||
if (resume) {
|
||||
void clock.refresh();
|
||||
if (!hostHidden && manifest && isLatest()) void show(manifest.latest);
|
||||
}
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
if (resume && enabled && !hostHidden && manifest) {
|
||||
const time =
|
||||
followLatest || !manifest.times.includes(shownTime())
|
||||
? manifest.latest
|
||||
: shownTime();
|
||||
if (
|
||||
time !== shownTime() ||
|
||||
(satellite && rendering.getDiagnostics().infrared !== infrared)
|
||||
)
|
||||
void show(time);
|
||||
else schedule();
|
||||
}
|
||||
notify();
|
||||
}
|
||||
const onMapStackChanged = () => checkHost();
|
||||
|
||||
function schedule() {
|
||||
if (clock) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
if (!enabled || !playing || suspended()) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
const times = manifest?.times || [];
|
||||
const next = (times.indexOf(shownTime()) + 1) % times.length;
|
||||
if (times[next]) void show(times[next]);
|
||||
else stop();
|
||||
}, 2000);
|
||||
}
|
||||
async function applyClockTime(time, { signal }) {
|
||||
if (!enabled || signal.aborted) return false;
|
||||
if (time !== null) return show(time, signal);
|
||||
++generation;
|
||||
frameRequest?.abort();
|
||||
frameRequest = null;
|
||||
noFrame = true;
|
||||
rendering?.setHidden(true);
|
||||
loading = Boolean(request && !request.signal.aborted);
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
function registerClock() {
|
||||
if (!clock || unregisterClock || !rendering || !enabled) return;
|
||||
unregisterClock = clock.register({
|
||||
id,
|
||||
get maxGapMs() {
|
||||
return maxGap();
|
||||
},
|
||||
getTimes: () => manifest?.times ?? [],
|
||||
getShownTime: shownTime,
|
||||
apply: applyClockTime,
|
||||
isSuspended: suspended,
|
||||
});
|
||||
}
|
||||
async function warmNext(time) {
|
||||
if (
|
||||
!enabled ||
|
||||
suspended() ||
|
||||
!(clock ? clock.getState().playing : playing)
|
||||
)
|
||||
return;
|
||||
const times = manifest?.times ?? [];
|
||||
const next = times[(times.indexOf(time) + 1) % times.length];
|
||||
if (!next || next === time) return;
|
||||
try {
|
||||
await rendering.prefetch?.(manifest, next, { infrared });
|
||||
} catch {
|
||||
// Speculative work must not change the displayed frame or playback state.
|
||||
}
|
||||
}
|
||||
async function show(time, signal) {
|
||||
checkHost(false);
|
||||
if (!enabled || hostHidden || !manifest?.times?.includes(time))
|
||||
return false;
|
||||
signal?.throwIfAborted();
|
||||
frameRequest?.abort();
|
||||
const controller = new AbortController();
|
||||
frameRequest = controller;
|
||||
const abort = () => controller.abort();
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const owner = ++generation;
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
const ok = await rendering.setFrame(manifest, time, {
|
||||
signal: controller.signal,
|
||||
infrared,
|
||||
});
|
||||
if (owner !== generation || !enabled || controller.signal.aborted)
|
||||
return false;
|
||||
if (ok) {
|
||||
noFrame = false;
|
||||
rendering.setHidden?.(false);
|
||||
void warmNext(time);
|
||||
}
|
||||
error = ok ? null : 'Frame unavailable; previous observation retained';
|
||||
if (!ok) stop();
|
||||
return ok;
|
||||
} catch {
|
||||
if (owner === generation && !controller.signal.aborted) {
|
||||
error = 'Weather imagery unavailable';
|
||||
stop();
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (frameRequest === controller) frameRequest = null;
|
||||
if (owner === generation) {
|
||||
loading = Boolean(request && !request.signal.aborted);
|
||||
notify();
|
||||
if (clock && isLatest()) void clock.refresh();
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
}
|
||||
const layer = {
|
||||
id,
|
||||
name: radar
|
||||
? 'Rain radar'
|
||||
: lightning
|
||||
? 'Lightning density'
|
||||
: 'Satellite clouds',
|
||||
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
|
||||
source: 'NOAA nowCOAST · OBSERVED',
|
||||
updateInterval: lightning ? 600_000 : 120_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost,
|
||||
onChange: notify,
|
||||
});
|
||||
checkHost(false);
|
||||
eventTarget?.addEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
onMapStackChanged,
|
||||
);
|
||||
rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
|
||||
motion = matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
motion?.addEventListener?.('change', onVisibility);
|
||||
documentRef?.addEventListener?.('visibilitychange', onVisibility);
|
||||
registerClock();
|
||||
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
|
||||
checkHost();
|
||||
if (enabled) notify();
|
||||
});
|
||||
},
|
||||
attachShellServices(services) {
|
||||
imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
checkHost();
|
||||
runNavigation =
|
||||
typeof services?.runNavigation === 'function'
|
||||
? services.runNavigation
|
||||
: null;
|
||||
notify();
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
registerClock();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
unregisterClock?.();
|
||||
unregisterClock = null;
|
||||
++generation;
|
||||
frameRequest?.abort();
|
||||
frameRequest = null;
|
||||
noFrame = false;
|
||||
request?.abort();
|
||||
request = null;
|
||||
stop();
|
||||
rendering?.clear();
|
||||
manifest = null;
|
||||
loading = false;
|
||||
error = null;
|
||||
followLatest = true;
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
if (!enabled) return false;
|
||||
checkHost(false);
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
if (signal?.aborted) controller.abort(signal.reason);
|
||||
request = controller;
|
||||
const abort = () => {
|
||||
controller.abort(signal.reason);
|
||||
stop();
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await feed.getSnapshot({
|
||||
product,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
if (snapshot.unavailable) {
|
||||
error = 'Weather source unavailable; previous observation retained';
|
||||
stop();
|
||||
return true;
|
||||
}
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
if (clock) {
|
||||
await clock.refresh();
|
||||
if (
|
||||
isLatest() &&
|
||||
request === controller &&
|
||||
!controller.signal.aborted
|
||||
)
|
||||
await show(snapshot.latest, controller.signal);
|
||||
return (
|
||||
!controller.signal.aborted && request === controller && enabled
|
||||
);
|
||||
}
|
||||
const time =
|
||||
followLatest || !snapshot.times.includes(shownTime())
|
||||
? snapshot.latest
|
||||
: shownTime();
|
||||
if (
|
||||
shownTime() !== time ||
|
||||
(satellite && rendering.getDiagnostics().infrared !== infrared)
|
||||
)
|
||||
await show(time, controller.signal);
|
||||
return !controller.signal.aborted && request === controller && enabled;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Weather unavailable';
|
||||
stop();
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = Boolean(rendering?.getDiagnostics().loading);
|
||||
notify();
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
const infraredChanged =
|
||||
satellite &&
|
||||
['filtered', 'full'].includes(params.infrared) &&
|
||||
params.infrared !== infrared;
|
||||
if (infraredChanged) infrared = params.infrared;
|
||||
if (['light', 'strong'].includes(params.opacity)) {
|
||||
opacity = params.opacity;
|
||||
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
|
||||
}
|
||||
if (
|
||||
satellite &&
|
||||
['clouds', 'clouds-regional'].includes(params.product) &&
|
||||
params.product !== product
|
||||
) {
|
||||
product = params.product;
|
||||
++generation;
|
||||
frameRequest?.abort();
|
||||
frameRequest = null;
|
||||
noFrame = false;
|
||||
request?.abort();
|
||||
request = null;
|
||||
stop();
|
||||
manifest = null;
|
||||
error = null;
|
||||
loading = false;
|
||||
followLatest = true;
|
||||
rendering?.clear();
|
||||
if (clock) void clock.refresh();
|
||||
if (enabled) void layer.update(viewer);
|
||||
}
|
||||
if (infraredChanged && enabled && manifest) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
if (clock && !isLatest()) void clock.refresh();
|
||||
else void show(shownTime() || manifest.latest);
|
||||
}
|
||||
if (
|
||||
params.focus === true &&
|
||||
enabled &&
|
||||
manifest?.bounds &&
|
||||
runNavigation
|
||||
) {
|
||||
const b = lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds;
|
||||
runNavigation(() =>
|
||||
viewer.camera.flyTo({
|
||||
destination: cesium.Rectangle.fromDegrees(
|
||||
b.west,
|
||||
b.south,
|
||||
b.east,
|
||||
b.north,
|
||||
),
|
||||
duration: motion?.matches ? 0 : 1.4,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (clock && enabled) {
|
||||
if (params.play === true) void clock.togglePlay();
|
||||
if (params.latest === true) void clock.latest();
|
||||
if ([-1, 1].includes(params.step)) void clock.step(params.step);
|
||||
}
|
||||
if (
|
||||
!clock &&
|
||||
params.play === true &&
|
||||
enabled &&
|
||||
manifest?.times?.length > 1 &&
|
||||
!suspended()
|
||||
) {
|
||||
playing = !playing;
|
||||
followLatest = false;
|
||||
if (playing) schedule();
|
||||
else stop();
|
||||
}
|
||||
if (!clock && params.latest === true && manifest) {
|
||||
stop();
|
||||
followLatest = true;
|
||||
void show(manifest.latest);
|
||||
}
|
||||
if (!clock && [-1, 1].includes(params.step) && manifest && !loading) {
|
||||
stop();
|
||||
followLatest = false;
|
||||
const at = manifest.times.indexOf(shownTime());
|
||||
const next = Math.max(
|
||||
0,
|
||||
Math.min(manifest.times.length - 1, at + params.step),
|
||||
);
|
||||
void show(manifest.times[next]);
|
||||
}
|
||||
notify();
|
||||
},
|
||||
getParams() {
|
||||
return radar
|
||||
? { opacity }
|
||||
: satellite
|
||||
? { product, opacity, infrared }
|
||||
: { product, opacity };
|
||||
},
|
||||
getRowControls() {
|
||||
const shared = clock?.getState();
|
||||
const followLatest = isLatest();
|
||||
const missing =
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
shared.products.find((entry) => entry.id === id)?.selected === null
|
||||
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
|
||||
: null;
|
||||
const time = shownTime();
|
||||
const relation =
|
||||
shared?.mode === 'history' && time
|
||||
? Date.parse(time) === Date.parse(shared.target)
|
||||
? ' · synced'
|
||||
: Date.parse(time) < Date.parse(shared.target)
|
||||
? ' · nearest'
|
||||
: ''
|
||||
: '';
|
||||
const times = manifest?.times || [];
|
||||
const index = times.indexOf(time);
|
||||
const current = index >= 0;
|
||||
const age = time
|
||||
? Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60000))
|
||||
: null;
|
||||
const lag =
|
||||
age === null
|
||||
? ''
|
||||
: age >= 60
|
||||
? `${Math.floor(age / 60)}h ${age % 60}m ago`
|
||||
: `${age}m ago`;
|
||||
const diagnostic = rendering?.getDiagnostics();
|
||||
const b = manifest?.bounds;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const point =
|
||||
canvas &&
|
||||
viewer.camera.pickEllipsoid?.(
|
||||
new cesium.Cartesian2(
|
||||
canvas.clientWidth / 2,
|
||||
canvas.clientHeight / 2,
|
||||
),
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
const camera = point
|
||||
? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid)
|
||||
: null;
|
||||
const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0;
|
||||
const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0;
|
||||
const outside =
|
||||
b &&
|
||||
camera &&
|
||||
(lon < b.west ||
|
||||
lon > b.east ||
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label: radar
|
||||
? 'Rain radar · US'
|
||||
: lightning
|
||||
? 'Lightning density · 15 min'
|
||||
: 'Satellite clouds',
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? 'Americas + Pacific'
|
||||
: product === 'clouds'
|
||||
? 'Global · 60°S–60°N'
|
||||
: 'North America',
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail: time
|
||||
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
|
||||
: missing
|
||||
? 'Observation unavailable'
|
||||
: 'Waiting for observation',
|
||||
status:
|
||||
missing ||
|
||||
hostStatus ||
|
||||
error ||
|
||||
diagnostic?.error ||
|
||||
(observationDelayed()
|
||||
? 'Source observations delayed'
|
||||
: manifest?.stale
|
||||
? 'Stale source'
|
||||
: loading
|
||||
? 'Loading next frame…'
|
||||
: outside
|
||||
? 'Map center outside coverage'
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
? [
|
||||
['clouds-regional', 'N. America'],
|
||||
['clouds', 'Global'],
|
||||
].map(([value, label]) => ({
|
||||
id: value,
|
||||
label,
|
||||
active: product === value,
|
||||
params: { product: value },
|
||||
title:
|
||||
value === 'clouds'
|
||||
? 'Hourly global mosaic; usually 2–3 hours delayed'
|
||||
: 'GOES regional clouds; approximately 5-minute updates',
|
||||
}))
|
||||
: []),
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'filtered',
|
||||
label: 'Clouds only',
|
||||
active: infrared === 'filtered',
|
||||
params: { infrared: 'filtered' },
|
||||
title:
|
||||
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
|
||||
},
|
||||
{
|
||||
id: 'full',
|
||||
label: 'Full',
|
||||
active: infrared === 'full',
|
||||
params: { infrared: 'full' },
|
||||
title: 'The complete infrared image at the chosen opacity',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...['light', 'strong'].map((value) => ({
|
||||
id: `opacity-${value}`,
|
||||
label: value === 'light' ? 'Soft' : 'Vivid',
|
||||
active: opacity === value,
|
||||
params: { opacity: value },
|
||||
title: 'Image opacity; does not alter the observed values',
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? 'View US radar'
|
||||
: lightning
|
||||
? 'View Americas & Pacific'
|
||||
: 'View coverage',
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
],
|
||||
legend:
|
||||
radar || lightning
|
||||
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? `${label} strikes/km²/min ×10³ (15-minute density)`
|
||||
: `${label} dBZ radar reflectivity`,
|
||||
}))
|
||||
: [],
|
||||
info: hostHidden
|
||||
? hostStatus
|
||||
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
|
||||
infoTitle: lightning
|
||||
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
|
||||
: radar
|
||||
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
|
||||
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
? [
|
||||
{
|
||||
id: 'region',
|
||||
label: 'REGION',
|
||||
chips: controls.chips.filter(({ params }) => params.product),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
label: 'IMAGE',
|
||||
chips: controls.chips.filter(({ params }) => params.infrared),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'opacity',
|
||||
label: 'OPACITY',
|
||||
chips: controls.chips.filter(({ params }) => params.opacity),
|
||||
},
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'coverage',
|
||||
);
|
||||
return controls;
|
||||
},
|
||||
setRowControlsListener(value) {
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
getStats() {
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? 'Observed' : 'History',
|
||||
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
stale: Boolean(manifest?.stale || observationDelayed()),
|
||||
source: 'NOAA nowCOAST',
|
||||
observedAt: shownTime(),
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
const shared = clock?.getState();
|
||||
return {
|
||||
...rendering?.getDiagnostics(),
|
||||
playing: shared ? shared.playing : playing && !hostHidden,
|
||||
followLatest: isLatest(),
|
||||
...(shared
|
||||
? {
|
||||
clock: {
|
||||
mode: shared.mode,
|
||||
target: shared.target,
|
||||
playing: shared.playing,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
historyFrames: manifest?.times?.length || 0,
|
||||
timerActive: timer !== null,
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock?.();
|
||||
removeCamera?.();
|
||||
removeCamera = null;
|
||||
motion?.removeEventListener?.('change', onVisibility);
|
||||
documentRef?.removeEventListener?.('visibilitychange', onVisibility);
|
||||
eventTarget?.removeEventListener?.(
|
||||
'gev:map-stack-changed',
|
||||
onMapStackChanged,
|
||||
);
|
||||
runNavigation = null;
|
||||
imageryHost = null;
|
||||
viewer = null;
|
||||
rendering = null;
|
||||
listener = null;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const INFRARED_ALPHA_T0 = 0.4;
|
||||
export const INFRARED_ALPHA_T1 = 0.7;
|
||||
|
||||
/** Return display pixels without changing RGB or the source RGBA bytes. */
|
||||
export function infraredAlpha(rgba, mode = 'filtered') {
|
||||
const pixels = new Uint8ClampedArray(rgba);
|
||||
if (mode === 'full') return pixels;
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
|
||||
const linearMax =
|
||||
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
|
||||
const t = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(linearMax - INFRARED_ALPHA_T0) /
|
||||
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
|
||||
),
|
||||
);
|
||||
pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t);
|
||||
}
|
||||
return pixels;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
infraredAlpha,
|
||||
INFRARED_ALPHA_T0,
|
||||
INFRARED_ALPHA_T1,
|
||||
} from './infraredAlpha.js';
|
||||
|
||||
test('filtered infrared preserves RGB and input bytes, scales existing alpha, and matches the old linear threshold', () => {
|
||||
assert.equal(INFRARED_ALPHA_T0, 0.4);
|
||||
assert.equal(INFRARED_ALPHA_T1, 0.7);
|
||||
const bytes = new Uint8ClampedArray([
|
||||
0, 0, 0, 255, 168, 12, 50, 255, 12, 194, 50, 200, 12, 50, 217, 128, 255,
|
||||
255, 255, 0,
|
||||
]);
|
||||
const original = bytes.slice();
|
||||
const result = infraredAlpha(bytes);
|
||||
assert.deepEqual(bytes, original);
|
||||
for (let i = 0; i < bytes.length; i += 4)
|
||||
assert.deepEqual(result.slice(i, i + 3), bytes.slice(i, i + 3));
|
||||
assert.deepEqual(
|
||||
[result[3], result[7], result[11], result[15], result[19]],
|
||||
[0, 0, 98, 128, 0],
|
||||
);
|
||||
// Cesium's old 0.55 hard cut falls between byte values 194 and 195.
|
||||
assert.ok((194 / 255) ** 2.2 < 0.55);
|
||||
assert.ok((195 / 255) ** 2.2 > 0.55);
|
||||
const ramp = [];
|
||||
for (let value = 0; value < 256; value++) {
|
||||
ramp.push(infraredAlpha([value, value, value, 255])[3]);
|
||||
}
|
||||
assert.ok(ramp[194] < 127.5 && ramp[195] > 127.5);
|
||||
assert.ok(ramp.every((value, i) => !i || value >= ramp[i - 1]));
|
||||
assert.ok(Math.max(...ramp.slice(1).map((v, i) => v - ramp[i])) <= 8);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
(0.55 - INFRARED_ALPHA_T0) / (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0) -
|
||||
0.5,
|
||||
) < 1e-12,
|
||||
);
|
||||
});
|
||||
|
||||
test('full infrared preserves every channel including partial and zero alpha', () => {
|
||||
const pixels = new Uint8ClampedArray([
|
||||
12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0,
|
||||
]);
|
||||
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import { weatherImageUrl } from './source.js';
|
||||
import { infraredAlpha } from './infraredAlpha.js';
|
||||
|
||||
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Apply the display transfer once to a decoded image. */
|
||||
export function processInfraredImage(image, mode, createCanvas) {
|
||||
const canvas = createCanvas();
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
pixels.data.set(infraredAlpha(pixels.data, mode));
|
||||
context.putImageData(pixels, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Decode locally, releasing the object URL on success, failure or cancellation. */
|
||||
export function decodeInfraredImage(blob, signal) {
|
||||
signal.throwIfAborted();
|
||||
if (typeof globalThis.createImageBitmap === 'function')
|
||||
return globalThis.createImageBitmap(blob);
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const cleanup = () => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
image.onload = image.onerror = null;
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const abort = () => {
|
||||
cleanup();
|
||||
image.src = '';
|
||||
reject(signal.reason);
|
||||
};
|
||||
image.onload = () => {
|
||||
cleanup();
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error('Infrared decode failed'));
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
|
||||
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
|
||||
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
|
||||
* image is always released. */
|
||||
export async function acquireWeatherImage(
|
||||
product,
|
||||
time,
|
||||
{
|
||||
signal,
|
||||
mode,
|
||||
size,
|
||||
bbox = null,
|
||||
maxBytes,
|
||||
createCanvas,
|
||||
fetchImpl,
|
||||
decodeImage = decodeInfraredImage,
|
||||
now = () => performance.now(),
|
||||
onFetched = () => {},
|
||||
},
|
||||
) {
|
||||
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
|
||||
const bytes = await readResponseBytesCapped(response, maxBytes);
|
||||
signal.throwIfAborted();
|
||||
onFetched();
|
||||
const started = now();
|
||||
const image = await decodeImage(
|
||||
new Blob([bytes], { type: 'image/png' }),
|
||||
signal,
|
||||
);
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
if (image.width !== size.width || image.height !== size.height)
|
||||
throw new Error('Invalid weather image dimensions');
|
||||
let texture;
|
||||
if (product === 'clouds' || product === 'clouds-regional')
|
||||
texture = processInfraredImage(image, mode, createCanvas);
|
||||
else {
|
||||
texture = createCanvas();
|
||||
texture.width = image.width;
|
||||
texture.height = image.height;
|
||||
texture.getContext('2d').drawImage(image, 0, 0);
|
||||
}
|
||||
return { texture, decodeMs: now() - started };
|
||||
} finally {
|
||||
image.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire one capped global mosaic before adding any imagery layer. */
|
||||
export function acquireInfraredMosaic(time, options) {
|
||||
return acquireWeatherImage('clouds', time, {
|
||||
...options,
|
||||
size: { width: 2048, height: 1024 },
|
||||
maxBytes: MAX_MOSAIC_BYTES,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
decodeInfraredImage,
|
||||
processInfraredImage,
|
||||
MAX_MOSAIC_BYTES,
|
||||
} from './infraredImage.js';
|
||||
|
||||
function canvas() {
|
||||
const output = {};
|
||||
output.getContext = () => ({
|
||||
drawImage: (image) => {
|
||||
output.image = image;
|
||||
},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([194, 0, 0, 200]) }),
|
||||
putImageData: (pixels) => {
|
||||
output.pixels = pixels.data;
|
||||
},
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
test('decoded pixels are processed once on a same-sized canvas', () => {
|
||||
const image = { width: 1, height: 1 };
|
||||
const output = processInfraredImage(image, 'filtered', canvas);
|
||||
assert.equal(output.width, 1);
|
||||
assert.equal(output.height, 1);
|
||||
assert.equal(output.image, image);
|
||||
assert.deepEqual([...output.pixels], [194, 0, 0, 98]);
|
||||
assert.deepEqual(
|
||||
[...processInfraredImage(image, 'full', canvas).pixels],
|
||||
[194, 0, 0, 200],
|
||||
);
|
||||
});
|
||||
|
||||
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
|
||||
const controller = new AbortController();
|
||||
let closes = 0,
|
||||
fetches = 0,
|
||||
clock = 10,
|
||||
fetched = false;
|
||||
const options = {
|
||||
signal: controller.signal,
|
||||
mode: 'filtered',
|
||||
createCanvas: canvas,
|
||||
now: () => clock,
|
||||
onFetched: () => {
|
||||
fetched = true;
|
||||
},
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
fetches++;
|
||||
assert.match(url, /^\/api\/weather\/image\?product=clouds&time=/);
|
||||
assert.equal(signal, controller.signal);
|
||||
return new Response(new Uint8Array([1, 2, 3]));
|
||||
},
|
||||
decodeImage: async (blob) => {
|
||||
assert.equal(fetched, true);
|
||||
assert.equal(blob.size, 3);
|
||||
assert.equal(blob.type, 'image/png');
|
||||
clock = 25;
|
||||
return {
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
close() {
|
||||
closes++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await acquireInfraredMosaic(
|
||||
'2026-09-15T20:00:00.000Z',
|
||||
options,
|
||||
);
|
||||
assert.equal(fetches, 1);
|
||||
assert.equal(closes, 1);
|
||||
assert.equal(result.decodeMs, 15);
|
||||
assert.equal(result.texture.width, 2048);
|
||||
for (const response of [
|
||||
new Response('', {
|
||||
headers: { 'content-length': String(MAX_MOSAIC_BYTES + 1) },
|
||||
}),
|
||||
new Response(new Uint8Array(MAX_MOSAIC_BYTES + 1)),
|
||||
]) {
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('2026-09-15', {
|
||||
...options,
|
||||
fetchImpl: async () => response,
|
||||
decodeImage: () => assert.fail('oversized image must not decode'),
|
||||
}),
|
||||
{ code: 'RESPONSE_TOO_LARGE' },
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('2026-09-15', {
|
||||
...options,
|
||||
decodeImage: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
width: 2048,
|
||||
height: 1024,
|
||||
close() {
|
||||
closes++;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
assert.equal(closes, 2);
|
||||
});
|
||||
|
||||
test('bitmap decoding and Image fallback release resources on success, error and abort', async (t) => {
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
'createImageBitmap',
|
||||
);
|
||||
t.after(() => {
|
||||
if (original)
|
||||
Object.defineProperty(globalThis, 'createImageBitmap', original);
|
||||
else delete globalThis.createImageBitmap;
|
||||
});
|
||||
const bitmap = {};
|
||||
globalThis.createImageBitmap = async () => bitmap;
|
||||
assert.equal(
|
||||
await decodeInfraredImage(new Blob(), new AbortController().signal),
|
||||
bitmap,
|
||||
);
|
||||
globalThis.createImageBitmap = undefined;
|
||||
const images = [],
|
||||
revoked = [];
|
||||
const originalImage = globalThis.Image;
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
images.push(this);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
if (originalImage) globalThis.Image = originalImage;
|
||||
else delete globalThis.Image;
|
||||
});
|
||||
t.mock.method(URL, 'createObjectURL', () => 'blob:test');
|
||||
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
|
||||
for (const action of ['load', 'error', 'abort']) {
|
||||
const controller = new AbortController();
|
||||
const pending = decodeInfraredImage(new Blob(), controller.signal);
|
||||
const image = images.at(-1);
|
||||
assert.equal(image.src, 'blob:test');
|
||||
if (action === 'load') {
|
||||
image.onload();
|
||||
assert.equal(await pending, image);
|
||||
} else if (action === 'error') {
|
||||
image.onerror();
|
||||
await assert.rejects(pending, /decode failed/);
|
||||
} else {
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(image.src, '');
|
||||
}
|
||||
assert.equal(image.onload, null);
|
||||
assert.equal(image.onerror, null);
|
||||
}
|
||||
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Tile a bounded decoded canvas without network requests or scene state. */
|
||||
export function createRasterTileProvider({
|
||||
cesium,
|
||||
texture,
|
||||
credit,
|
||||
createCanvas,
|
||||
rectangle = cesium.Rectangle.MAX_VALUE,
|
||||
tilingScheme = new cesium.GeographicTilingScheme({ rectangle }),
|
||||
maximumLevel = 2,
|
||||
tileSize = 256,
|
||||
}) {
|
||||
return {
|
||||
tilingScheme,
|
||||
rectangle,
|
||||
tileWidth: tileSize,
|
||||
tileHeight: tileSize,
|
||||
minimumLevel: 0,
|
||||
maximumLevel,
|
||||
ready: true,
|
||||
tileDiscardPolicy: undefined,
|
||||
credit,
|
||||
errorEvent: new cesium.Event(),
|
||||
hasAlphaChannel: true,
|
||||
getTileCredits: () => undefined,
|
||||
pickFeatures: () => undefined,
|
||||
requestImage(x, y, level) {
|
||||
const tile = createCanvas();
|
||||
tile.width = tile.height = tileSize;
|
||||
const ctx = tile.getContext('2d');
|
||||
const width =
|
||||
texture.width / tilingScheme.getNumberOfXTilesAtLevel(level);
|
||||
const height =
|
||||
texture.height / tilingScheme.getNumberOfYTilesAtLevel(level);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.drawImage(
|
||||
texture,
|
||||
x * width,
|
||||
y * height,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
0,
|
||||
tileSize,
|
||||
tileSize,
|
||||
);
|
||||
// ImageryLayer consumes requestImage results as promises.
|
||||
return Promise.resolve(tile);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createFieldRaster } from '../wind/fields.js';
|
||||
|
||||
// A small canvas double exposes the uploaded raster and exact drawImage crop.
|
||||
// Verify crop coordinates and source-cell selection independently of canvas smoothing.
|
||||
function createCanvas() {
|
||||
const canvas = { width: 0, height: 0 };
|
||||
const context = {
|
||||
createImageData: (width, height) => ({
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
}),
|
||||
putImageData(pixels) {
|
||||
canvas.rgba = pixels.data;
|
||||
},
|
||||
drawImage(source, ...args) {
|
||||
canvas.source = source;
|
||||
canvas.crop = args;
|
||||
},
|
||||
getImageData(x, y) {
|
||||
const [sx, sy, sw, sh, dx, dy, dw, dh] = canvas.crop;
|
||||
const col = Math.floor(sx + ((x - dx + 0.5) * sw) / dw);
|
||||
const row = Math.floor(sy + ((y - dy + 0.5) * sh) / dh);
|
||||
const offset = (row * canvas.source.width + col) * 4;
|
||||
return { data: canvas.source.rgba.slice(offset, offset + 4) };
|
||||
},
|
||||
};
|
||||
canvas.getContext = () => context;
|
||||
return canvas;
|
||||
}
|
||||
|
||||
test('real Cesium geographic provider crops level 0/1 tiles from a decoded canvas with preserved pixels', async () => {
|
||||
const raster = createFieldRaster(
|
||||
{
|
||||
nx: 8,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 45,
|
||||
dy: 90,
|
||||
u: Float32Array.from([
|
||||
0, 0, 10, 10, 20, 20, 30, 30, 0, 0, 10, 10, 20, 20, 30, 30, 30, 30, 20,
|
||||
20, 10, 10, 0, 0,
|
||||
]),
|
||||
v: new Float32Array(24),
|
||||
},
|
||||
'speed',
|
||||
720,
|
||||
362,
|
||||
);
|
||||
const credit = new Cesium.Credit('NOAA GFS');
|
||||
const texture = {
|
||||
width: raster.width,
|
||||
height: raster.height,
|
||||
rgba: raster.rgba,
|
||||
};
|
||||
const provider = createRasterTileProvider({
|
||||
cesium: Cesium,
|
||||
texture,
|
||||
credit,
|
||||
createCanvas,
|
||||
});
|
||||
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
|
||||
assert.equal(provider.rectangle, Cesium.Rectangle.MAX_VALUE);
|
||||
assert.equal(provider.minimumLevel, 0);
|
||||
assert.equal(provider.maximumLevel, 2);
|
||||
assert.equal(provider.tileWidth, 256);
|
||||
assert.equal(provider.tileHeight, 256);
|
||||
assert.equal(provider.ready, true);
|
||||
assert.equal(provider.hasAlphaChannel, true);
|
||||
assert.equal(provider.credit, credit);
|
||||
assert.ok(provider.errorEvent instanceof Cesium.Event);
|
||||
assert.equal(provider.tileDiscardPolicy, undefined);
|
||||
assert.equal(provider.getTileCredits(0, 0, 0), undefined);
|
||||
assert.equal(provider.pickFeatures(0, 0, 0, 0, 0), undefined);
|
||||
for (const [x, y, level, crop, cell] of [
|
||||
[0, 0, 0, [0, 0, 360, 362, 0, 0, 256, 256], [90, 91]],
|
||||
[1, 0, 0, [360, 0, 360, 362, 0, 0, 256, 256], [450, 91]],
|
||||
[0, 0, 1, [0, 0, 180, 181, 0, 0, 256, 256], [45, 45]],
|
||||
[3, 1, 1, [540, 181, 180, 181, 0, 0, 256, 256], [585, 226]],
|
||||
]) {
|
||||
const pending = provider.requestImage(x, y, level);
|
||||
assert.ok(pending instanceof Promise, 'ImageryLayer consumes a promise');
|
||||
const tile = await pending;
|
||||
assert.equal(tile.width, 256);
|
||||
assert.equal(tile.height, 256);
|
||||
assert.equal(tile.getContext('2d').imageSmoothingEnabled, true);
|
||||
assert.deepEqual(tile.crop, crop);
|
||||
const offset = (cell[1] * 720 + cell[0]) * 4;
|
||||
assert.deepEqual(
|
||||
tile.getContext('2d').getImageData(64, 64).data,
|
||||
raster.rgba.slice(offset, offset + 4),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('bounded decoded mosaic crops both roots and higher levels without resampling its extent', async () => {
|
||||
const texture = { width: 2048, height: 1024 };
|
||||
const rectangle = Cesium.Rectangle.fromDegrees(-180, -60, 180, 60);
|
||||
const scheme = new Cesium.GeographicTilingScheme({
|
||||
rectangle,
|
||||
numberOfLevelZeroTilesX: 2,
|
||||
numberOfLevelZeroTilesY: 1,
|
||||
});
|
||||
const credit = new Cesium.Credit('NOAA');
|
||||
const provider = createRasterTileProvider({
|
||||
cesium: Cesium,
|
||||
texture,
|
||||
rectangle,
|
||||
tilingScheme: scheme,
|
||||
maximumLevel: 3,
|
||||
credit,
|
||||
createCanvas,
|
||||
});
|
||||
assert.equal(provider.rectangle, rectangle);
|
||||
assert.equal(provider.maximumLevel, 3);
|
||||
assert.equal(provider.credit, credit);
|
||||
assert.equal(provider.hasAlphaChannel, true);
|
||||
for (const [x, y, level, crop] of [
|
||||
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 256, 256]],
|
||||
[1, 0, 0, [1024, 0, 1024, 1024, 0, 0, 256, 256]],
|
||||
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 256, 256]],
|
||||
]) {
|
||||
const pending = provider.requestImage(x, y, level);
|
||||
assert.ok(pending instanceof Promise);
|
||||
const tile = await pending;
|
||||
assert.equal(tile.source, texture);
|
||||
assert.deepEqual(tile.crop, crop);
|
||||
}
|
||||
});
|
||||
|
||||
test('512-pixel crops preserve source extent at root and clamped draping levels', async () => {
|
||||
const texture = { width: 2048, height: 1024 };
|
||||
const provider = createRasterTileProvider({
|
||||
cesium: Cesium,
|
||||
texture,
|
||||
createCanvas,
|
||||
tileSize: 512,
|
||||
maximumLevel: 3,
|
||||
});
|
||||
assert.equal(provider.tileWidth, 512);
|
||||
assert.equal(provider.tileHeight, 512);
|
||||
for (const [x, y, level, crop] of [
|
||||
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 512, 512]],
|
||||
[3, 1, 1, [1536, 512, 512, 512, 0, 0, 512, 512]],
|
||||
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 512, 512]],
|
||||
]) {
|
||||
const tile = await provider.requestImage(x, y, level);
|
||||
assert.equal(tile.width, 512);
|
||||
assert.equal(tile.height, 512);
|
||||
assert.equal(tile.source, texture);
|
||||
assert.deepEqual(tile.crop, crop);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
import { weatherTileUrl } from './source.js';
|
||||
import { orderWeatherImagery } from './imageryOrder.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createWeatherShell } from './shellRendering.js';
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
processInfraredImage,
|
||||
} from './infraredImage.js';
|
||||
// Bounded display detail for the hourly, approximately 3 km global product.
|
||||
const GLOBAL_TILE_MAXIMUM_LEVEL = 3;
|
||||
// Six decoded 2048×1024 canvases (8 MiB each) bound the cache below 50 MiB.
|
||||
const MAX_MOSAICS = 6;
|
||||
const MAX_PREFETCH_TILES = 8;
|
||||
|
||||
/** Globe imagery: own at most a displayed and a staging frame. Use native Cesium
|
||||
* tile scheduling, projection and texture disposal; the application clock is never touched. */
|
||||
function createGlobeRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost,
|
||||
onChange,
|
||||
timeoutMs,
|
||||
now,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
createCanvas,
|
||||
}) {
|
||||
let current = null;
|
||||
let incoming = null;
|
||||
const retiring = new Set();
|
||||
let alpha = 0.7;
|
||||
let frameHidden = false;
|
||||
let lastError = null;
|
||||
const mosaics = new Map();
|
||||
let prefetchJob = null;
|
||||
let prefetchedKey = null;
|
||||
|
||||
function profile(product) {
|
||||
return {
|
||||
tileSize: 256,
|
||||
maximumLevel: product === 'clouds' ? GLOBAL_TILE_MAXIMUM_LEVEL : 6,
|
||||
};
|
||||
}
|
||||
|
||||
function cancelPrefetch() {
|
||||
clearTimeout(prefetchJob?.timeout);
|
||||
prefetchJob?.controller.abort();
|
||||
prefetchJob = null;
|
||||
prefetchedKey = null;
|
||||
}
|
||||
|
||||
async function mosaic(time, mode, signal, onFetched) {
|
||||
signal.throwIfAborted();
|
||||
const key = `${time}|${mode}`;
|
||||
const texture = mosaics.get(key);
|
||||
if (texture) {
|
||||
mosaics.delete(key);
|
||||
mosaics.set(key, texture);
|
||||
return { texture, decodeMs: 0, cached: true };
|
||||
}
|
||||
const result = await acquireInfraredMosaic(time, {
|
||||
signal,
|
||||
mode,
|
||||
createCanvas,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
now,
|
||||
onFetched,
|
||||
});
|
||||
// An aborted decode may still finish; never repopulate a cleared instance.
|
||||
signal.throwIfAborted();
|
||||
mosaics.delete(key);
|
||||
mosaics.set(key, result.texture);
|
||||
while (mosaics.size > MAX_MOSAICS)
|
||||
mosaics.delete(mosaics.keys().next().value);
|
||||
return { ...result, cached: false };
|
||||
}
|
||||
|
||||
function prefetchTiles(snapshot, time) {
|
||||
const { west, south, east, north } = snapshot.bounds;
|
||||
const bounds = cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
const view = viewer.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
const coverage = view
|
||||
? cesium.Rectangle.intersection(bounds, view)
|
||||
: bounds;
|
||||
if (!coverage) return [];
|
||||
const scheme = new cesium.GeographicTilingScheme();
|
||||
const template = weatherTileUrl(snapshot.product, time, {
|
||||
size: profile(snapshot.product).tileSize,
|
||||
});
|
||||
const urls = [];
|
||||
for (let z = 0; z <= 1; z++) {
|
||||
for (let y = 0; y < scheme.getNumberOfYTilesAtLevel(z); y++) {
|
||||
for (let x = 0; x < scheme.getNumberOfXTilesAtLevel(z); x++) {
|
||||
if (
|
||||
!cesium.Rectangle.intersection(
|
||||
coverage,
|
||||
scheme.tileXYToRectangle(x, y, z),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
urls.push(
|
||||
template.replace('{z}', z).replace('{x}', x).replace('{y}', y),
|
||||
);
|
||||
if (urls.length === MAX_PREFETCH_TILES) return urls;
|
||||
}
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
function remove(frame) {
|
||||
if (!frame) return;
|
||||
frame.closed = true;
|
||||
frame.controller.abort();
|
||||
frame.offRetire?.();
|
||||
frame.offInstall?.();
|
||||
retiring.delete(frame);
|
||||
frame.offError?.();
|
||||
frame.offAbort?.();
|
||||
frame.offRender?.();
|
||||
frame.offCamera?.();
|
||||
clearTimeout(frame.timeout);
|
||||
for (const request of frame.requests) request.cancel?.();
|
||||
frame.requests.clear();
|
||||
frame.retries.clear();
|
||||
if (!frame.layer) return;
|
||||
const collection = frame.collection;
|
||||
if (
|
||||
collection &&
|
||||
!collection.isDestroyed?.() &&
|
||||
collection.contains(frame.layer)
|
||||
)
|
||||
collection.remove(frame.layer, true);
|
||||
else if (!frame.layer.isDestroyed?.()) frame.layer.destroy?.();
|
||||
}
|
||||
function cancelIncoming() {
|
||||
if (!incoming) return;
|
||||
const previous = incoming;
|
||||
incoming = null;
|
||||
remove(previous);
|
||||
previous.resolve(false);
|
||||
}
|
||||
function rehome() {
|
||||
const host = getHost();
|
||||
const { collection } = host;
|
||||
const hidden = frameHidden || imageryHostStatus(host) !== null;
|
||||
const changed =
|
||||
(current && current.collection !== collection) ||
|
||||
(incoming && incoming.collection !== collection);
|
||||
const visibilityChanged = current && current.layer.show === hidden;
|
||||
if (changed || imageryHostStatus(host) !== null) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
for (const frame of retiring) remove(frame);
|
||||
}
|
||||
if (current) {
|
||||
if (!hidden && current.layer.alpha !== alpha) current.layer.alpha = alpha;
|
||||
if (visibilityChanged) current.layer.show = !hidden;
|
||||
}
|
||||
if (current && current.collection !== collection) {
|
||||
current.collection?.remove(current.layer, false);
|
||||
current.collection = collection;
|
||||
if (collection) {
|
||||
collection.add(current.layer);
|
||||
orderWeatherImagery(collection, current.layer, current.priority);
|
||||
}
|
||||
}
|
||||
if (changed || visibilityChanged) viewer.scene.requestRender();
|
||||
return Boolean(changed || visibilityChanged);
|
||||
}
|
||||
return {
|
||||
rehome,
|
||||
cancelPrefetch,
|
||||
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
|
||||
let job;
|
||||
try {
|
||||
if (
|
||||
frameHidden ||
|
||||
incoming ||
|
||||
imageryHostStatus(getHost()) ||
|
||||
!snapshot.times.includes(time)
|
||||
)
|
||||
return false;
|
||||
const global = snapshot.product === 'clouds';
|
||||
const urls = global ? [] : prefetchTiles(snapshot, time);
|
||||
const key = global ? `${time}|${infrared}` : urls.join('|');
|
||||
if (prefetchJob?.key === key || prefetchedKey === key) return false;
|
||||
cancelPrefetch();
|
||||
job = { key, controller: new AbortController() };
|
||||
prefetchJob = job;
|
||||
const { signal } = job.controller;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
if (global) await mosaic(time, infrared, signal);
|
||||
else
|
||||
await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const response = await fetchImpl(url, { signal });
|
||||
if (!response.ok) throw new Error('Weather prefetch unavailable');
|
||||
await readResponseBytesCapped(response, 4 * 1024 * 1024 + 65_536);
|
||||
}),
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (prefetchJob === job) prefetchedKey = key;
|
||||
return true;
|
||||
} catch {
|
||||
job?.controller.abort();
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(job?.timeout);
|
||||
if (job && prefetchJob === job) prefetchJob = null;
|
||||
}
|
||||
},
|
||||
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
cancelPrefetch();
|
||||
rehome();
|
||||
cancelIncoming();
|
||||
const host = getHost();
|
||||
const { collection, kind } = host;
|
||||
if (imageryHostStatus(host)) return false;
|
||||
const nextProfile = profile(snapshot.product);
|
||||
if (
|
||||
current?.time === time &&
|
||||
current.product === snapshot.product &&
|
||||
current.kind === kind &&
|
||||
current.infrared === infrared
|
||||
)
|
||||
return true;
|
||||
lastError = null;
|
||||
const { west, south, east, north } = snapshot.bounds;
|
||||
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
const global = snapshot.product === 'clouds';
|
||||
const frame = {
|
||||
snapshot,
|
||||
time,
|
||||
infrared,
|
||||
mosaic: global
|
||||
? { fetched: false, decodeMs: null, cached: false }
|
||||
: undefined,
|
||||
controller: new AbortController(),
|
||||
collection,
|
||||
kind,
|
||||
priority:
|
||||
snapshot.product === 'lightning'
|
||||
? 3
|
||||
: snapshot.product === 'radar'
|
||||
? 2
|
||||
: 1,
|
||||
product: snapshot.product,
|
||||
requests: new Set(),
|
||||
retries: new Map(),
|
||||
deferred: new Set(),
|
||||
pending: 0,
|
||||
loaded: 0,
|
||||
lastActivity: now(),
|
||||
startedAt: now(),
|
||||
closed: false,
|
||||
failed: false,
|
||||
resolve: null,
|
||||
};
|
||||
incoming = frame;
|
||||
const result = new Promise((resolve) => {
|
||||
frame.resolve = resolve;
|
||||
});
|
||||
const finish = (ok) => {
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
frame.offRender?.();
|
||||
frame.offRender = null;
|
||||
frame.offCamera?.();
|
||||
frame.offCamera = null;
|
||||
clearTimeout(frame.timeout);
|
||||
frame.offAbort?.();
|
||||
if (ok) {
|
||||
lastError = null;
|
||||
const previous = current;
|
||||
current = frame;
|
||||
frame.loadMs = now() - frame.startedAt;
|
||||
frame.layer.alpha = alpha;
|
||||
frame.layer.show = !frameHidden;
|
||||
viewer.scene.requestRender();
|
||||
if (previous) {
|
||||
retiring.add(previous);
|
||||
previous.offRetire = viewer.scene.postRender.addEventListener(
|
||||
() => {
|
||||
remove(previous);
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
remove(frame);
|
||||
}
|
||||
frame.resolve(ok);
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
};
|
||||
const abort = () => {
|
||||
if (incoming === frame) cancelIncoming();
|
||||
viewer.scene.requestRender();
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
frame.offAbort = () => signal?.removeEventListener('abort', abort);
|
||||
frame.timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
const install = (texture) => {
|
||||
if (frame.closed) return;
|
||||
// Keep at most two installed layers during rapid successive selections.
|
||||
if (retiring.size) {
|
||||
frame.offInstall = viewer.scene.postRender.addEventListener(() => {
|
||||
frame.offInstall();
|
||||
frame.offInstall = null;
|
||||
try {
|
||||
install(texture);
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
return;
|
||||
}
|
||||
const tilingScheme = new cesium.GeographicTilingScheme(
|
||||
global
|
||||
? {
|
||||
rectangle,
|
||||
numberOfLevelZeroTilesX: 2,
|
||||
numberOfLevelZeroTilesY: 1,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const credit = new cesium.Credit(
|
||||
snapshot.product === 'lightning'
|
||||
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
|
||||
: snapshot.product === 'radar'
|
||||
? 'NOAA nowCOAST · NWS/OAR MRMS'
|
||||
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
|
||||
false,
|
||||
);
|
||||
const provider = global
|
||||
? createRasterTileProvider({
|
||||
cesium,
|
||||
texture,
|
||||
rectangle,
|
||||
tilingScheme,
|
||||
maximumLevel: GLOBAL_TILE_MAXIMUM_LEVEL,
|
||||
tileSize: nextProfile.tileSize,
|
||||
credit,
|
||||
createCanvas,
|
||||
})
|
||||
: new cesium.UrlTemplateImageryProvider({
|
||||
url: weatherTileUrl(snapshot.product, time, {
|
||||
size: nextProfile.tileSize,
|
||||
}),
|
||||
tilingScheme,
|
||||
rectangle,
|
||||
tileWidth: nextProfile.tileSize,
|
||||
tileHeight: nextProfile.tileSize,
|
||||
maximumLevel: nextProfile.maximumLevel,
|
||||
enablePickFeatures: false,
|
||||
credit,
|
||||
});
|
||||
const requestImage = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (frame.closed) return undefined;
|
||||
const result = requestImage(x, y, level, request);
|
||||
const tileKey = `${level}/${x}/${y}`;
|
||||
if (!result) {
|
||||
// Scheduler admission is part of readiness, not a successful tile.
|
||||
frame.deferred.add(tileKey);
|
||||
frame.lastActivity = now();
|
||||
return result;
|
||||
}
|
||||
frame.deferred.delete(tileKey);
|
||||
frame.pending++;
|
||||
frame.lastActivity = now();
|
||||
if (request) frame.requests.add(request);
|
||||
return Promise.resolve(result)
|
||||
.then((image) => {
|
||||
if (!frame.closed && snapshot.product === 'clouds-regional')
|
||||
image = processInfraredImage(image, infrared, createCanvas);
|
||||
frame.retries.delete(tileKey);
|
||||
frame.loaded++;
|
||||
return image;
|
||||
})
|
||||
.finally(() => {
|
||||
frame.pending--;
|
||||
frame.lastActivity = now();
|
||||
frame.requests.delete(request);
|
||||
if (!frame.closed) viewer.scene.requestRender();
|
||||
});
|
||||
};
|
||||
frame.offError = provider.errorEvent.addEventListener((error) => {
|
||||
if (frame.closed) return;
|
||||
const status = error?.error?.statusCode;
|
||||
// Cesium retries synchronously after this event; no delay hook is exposed.
|
||||
const tileKey = `${error.level}/${error.x}/${error.y}`;
|
||||
const retries = frame.retries.get(tileKey) ?? 0;
|
||||
error.retry = false;
|
||||
if ((status === 429 || status === 503) && retries < 3) {
|
||||
frame.retries.set(tileKey, retries + 1);
|
||||
error.retry = true;
|
||||
return;
|
||||
}
|
||||
frame.failed = true;
|
||||
lastError = 'Some weather tiles unavailable';
|
||||
onChange();
|
||||
});
|
||||
// A shown, transparent layer lets Cesium request staging tiles while the last
|
||||
// complete observation stays visible underneath it.
|
||||
frame.layer = collection.addImageryProvider(provider);
|
||||
frame.layer.alpha = 0;
|
||||
orderWeatherImagery(collection, frame.layer, frame.priority);
|
||||
let settled = 0;
|
||||
frame.offCamera = viewer.camera?.moveEnd?.addEventListener(() => {
|
||||
// Tiles abandoned by a previous viewport are no longer admission work.
|
||||
frame.deferred.clear();
|
||||
frame.lastActivity = now();
|
||||
settled = 0;
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
frame.offRender = viewer.scene.postRender.addEventListener(() => {
|
||||
if (frame.failed) return finish(false);
|
||||
// Unrelated terrain/basemap work must not indefinitely hold a ready
|
||||
// observation. Require successful own tiles and a quiet scheduling
|
||||
// interval before admitting a frame when the rest of the globe is busy.
|
||||
const ownReady =
|
||||
frame.loaded > 0 &&
|
||||
frame.deferred.size === 0 &&
|
||||
now() - frame.lastActivity >= 200;
|
||||
if (
|
||||
((frame.kind === 'globe' && viewer.scene.globe.tilesLoaded) ||
|
||||
ownReady) &&
|
||||
frame.pending === 0
|
||||
) {
|
||||
if (++settled >= 2) finish(true);
|
||||
else viewer.scene.requestRender();
|
||||
} else settled = 0;
|
||||
if (incoming === frame && frame.pending === 0 && frame.loaded > 0)
|
||||
viewer.scene.requestRender();
|
||||
});
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
};
|
||||
if (global) {
|
||||
void mosaic(time, infrared, frame.controller.signal, () => {
|
||||
frame.mosaic.fetched = true;
|
||||
})
|
||||
.then(({ texture, decodeMs, cached }) => {
|
||||
frame.mosaic.decodeMs = decodeMs;
|
||||
frame.mosaic.cached = cached;
|
||||
install(texture);
|
||||
})
|
||||
.catch(() => finish(false));
|
||||
} else {
|
||||
try {
|
||||
install();
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
}
|
||||
viewer.scene.requestRender();
|
||||
onChange();
|
||||
return result;
|
||||
},
|
||||
setHidden(value) {
|
||||
frameHidden = Boolean(value);
|
||||
if (frameHidden) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
for (const frame of retiring) remove(frame);
|
||||
}
|
||||
rehome();
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
setAlpha(value) {
|
||||
alpha = value;
|
||||
if (current && current.layer.show) current.layer.alpha = alpha;
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
clear() {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
remove(current);
|
||||
for (const frame of retiring) remove(frame);
|
||||
current = null;
|
||||
frameHidden = false;
|
||||
lastError = null;
|
||||
mosaics.clear();
|
||||
viewer.scene.requestRender();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
host: 'globe',
|
||||
cache: { mosaics: mosaics.size, prefetching: !!prefetchJob },
|
||||
imageryCount:
|
||||
Number(!!current) + Number(!!incoming?.layer) + retiring.size,
|
||||
mosaic: (incoming || current)?.mosaic,
|
||||
infrared: (incoming || current)?.infrared ?? 'filtered',
|
||||
loading: !!incoming,
|
||||
time: frameHidden ? null : (current?.time ?? null),
|
||||
hidden: frameHidden,
|
||||
product: current?.product ?? null,
|
||||
pendingTiles: incoming?.pending ?? 0,
|
||||
deferredTiles: incoming?.deferred.size ?? 0,
|
||||
loadedTiles: (incoming || current)?.loaded ?? 0,
|
||||
frameLoadMs: current?.loadMs ?? null,
|
||||
error: imageryHostStatus(getHost()) || lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Globe hosts keep draped imagery; 3D Tiles hosts get a raised shell per product.
|
||||
* A host switch tears one renderer down and restages the last frame on the other. */
|
||||
export function createWeatherRendering({
|
||||
viewer,
|
||||
cesium,
|
||||
getHost = () => ({ collection: viewer.imageryLayers, kind: 'globe' }),
|
||||
onChange = () => {},
|
||||
timeoutMs = 25_000,
|
||||
now = () => performance.now(),
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
decodeImage,
|
||||
createCanvas = () => document.createElement('canvas'),
|
||||
createShell = createWeatherShell,
|
||||
}) {
|
||||
const shared = {
|
||||
viewer,
|
||||
cesium,
|
||||
getHost,
|
||||
onChange,
|
||||
timeoutMs,
|
||||
now,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
createCanvas,
|
||||
};
|
||||
const globe = createGlobeRendering(shared);
|
||||
let shell = null;
|
||||
let active = null;
|
||||
let alpha = 0.7;
|
||||
let hidden = false;
|
||||
let shown = null;
|
||||
let restaging = null;
|
||||
|
||||
// A host without imagery keeps the active renderer, which retains its frame.
|
||||
const wanted = () => {
|
||||
const { kind } = getHost();
|
||||
return kind === 'tileset'
|
||||
? 'shell'
|
||||
: kind === 'globe'
|
||||
? 'globe'
|
||||
: (active ?? 'globe');
|
||||
};
|
||||
const renderer = () => (active === 'shell' ? shell : globe);
|
||||
function shellFor(product) {
|
||||
if (shell?.product === product) return shell;
|
||||
shell?.clear();
|
||||
shell = createShell({ ...shared, product });
|
||||
shell.setAlpha(alpha);
|
||||
shell.setHidden(hidden);
|
||||
return shell;
|
||||
}
|
||||
function restage(frame) {
|
||||
const target =
|
||||
active === 'shell' ? shellFor(frame.snapshot.product) : globe;
|
||||
restaging = frame;
|
||||
const settle = (ok) => {
|
||||
if (restaging !== frame) return;
|
||||
restaging = null;
|
||||
if (!ok) onChange();
|
||||
};
|
||||
target
|
||||
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
|
||||
.then(settle, () => settle(false));
|
||||
}
|
||||
function switchHost() {
|
||||
const next = wanted();
|
||||
if (next === active) return false;
|
||||
const previous = active;
|
||||
active = next;
|
||||
if (previous === null) return false;
|
||||
restaging = null;
|
||||
if (previous === 'shell') {
|
||||
shell?.clear();
|
||||
shell = null;
|
||||
} else globe.clear();
|
||||
if (active === 'globe') {
|
||||
globe.setAlpha(alpha);
|
||||
if (hidden) globe.setHidden(true);
|
||||
}
|
||||
if (shown && !hidden) restage(shown);
|
||||
viewer.scene.requestRender();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
rehome() {
|
||||
const switched = switchHost();
|
||||
if (active === 'shell' && !shell) return switched;
|
||||
return renderer().rehome() || switched;
|
||||
},
|
||||
cancelPrefetch() {
|
||||
renderer()?.cancelPrefetch();
|
||||
},
|
||||
prefetch(snapshot, time, options) {
|
||||
switchHost();
|
||||
if (active === 'shell' && shell?.product !== snapshot.product)
|
||||
return Promise.resolve(false);
|
||||
return renderer().prefetch(snapshot, time, options);
|
||||
},
|
||||
async setFrame(snapshot, time, options = {}) {
|
||||
options.signal?.throwIfAborted();
|
||||
switchHost();
|
||||
restaging = null;
|
||||
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
|
||||
const ok = await target.setFrame(snapshot, time, options);
|
||||
if (ok)
|
||||
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
|
||||
return ok;
|
||||
},
|
||||
setHidden(value) {
|
||||
hidden = Boolean(value);
|
||||
if (hidden) restaging = null;
|
||||
renderer()?.setHidden(hidden);
|
||||
},
|
||||
setAlpha(value) {
|
||||
alpha = value;
|
||||
renderer()?.setAlpha(value);
|
||||
},
|
||||
clear() {
|
||||
restaging = null;
|
||||
shown = null;
|
||||
hidden = false;
|
||||
shell?.clear();
|
||||
shell = null;
|
||||
globe.clear();
|
||||
},
|
||||
getDiagnostics() {
|
||||
const diagnostics =
|
||||
active === 'shell' && shell
|
||||
? shell.getDiagnostics()
|
||||
: active === 'shell'
|
||||
? {
|
||||
host: 'shell',
|
||||
cache: { mosaics: 0, bytes: 0, prefetching: false },
|
||||
imageryCount: 0,
|
||||
infrared: 'filtered',
|
||||
loading: false,
|
||||
time: null,
|
||||
hidden,
|
||||
product: null,
|
||||
frameLoadMs: null,
|
||||
error: imageryHostStatus(getHost()),
|
||||
}
|
||||
: globe.getDiagnostics();
|
||||
// A host switch restages the retained frame; keep reporting its time.
|
||||
return restaging && !hidden && diagnostics.time === null
|
||||
? { ...diagnostics, time: restaging.time }
|
||||
: diagnostics;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Test double for the Cesium classes a weather shell constructs. */
|
||||
export function createShellCesium({ maximumTextureSize = 0 } = {}) {
|
||||
const templates = new Map();
|
||||
const created = { materials: [], primitives: [], appearances: [] };
|
||||
class Material {
|
||||
constructor(options) {
|
||||
const template = templates.get(options.fabric.type);
|
||||
this.options = options;
|
||||
this.uniforms = {
|
||||
...template.fabric.uniforms,
|
||||
imageDimensions: { type: 'ivec3', x: 1, y: 1 },
|
||||
detailDimensions: { type: 'ivec3', x: 1, y: 1 },
|
||||
};
|
||||
this.destroyed = false;
|
||||
created.materials.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
Material.DefaultImageId = Cesium.Material.DefaultImageId;
|
||||
Material._materialCache = {
|
||||
getMaterial: (type) => templates.get(type),
|
||||
addMaterial: (type, template) => templates.set(type, template),
|
||||
};
|
||||
class Primitive {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.appearance = options.appearance;
|
||||
this.show = options.show ?? true;
|
||||
this.ready = false;
|
||||
this.destroyed = false;
|
||||
created.primitives.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class EllipsoidSurfaceAppearance {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.material = options.material;
|
||||
created.appearances.push(this);
|
||||
}
|
||||
}
|
||||
EllipsoidSurfaceAppearance.VERTEX_FORMAT =
|
||||
Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT;
|
||||
class RectangleGeometry {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
}
|
||||
class GeometryInstance {
|
||||
constructor(options) {
|
||||
this.geometry = options.geometry;
|
||||
}
|
||||
}
|
||||
return {
|
||||
Material,
|
||||
Primitive,
|
||||
EllipsoidSurfaceAppearance,
|
||||
RectangleGeometry,
|
||||
GeometryInstance,
|
||||
BlendingState: Cesium.BlendingState,
|
||||
Cartesian4: Cesium.Cartesian4,
|
||||
Math: Cesium.Math,
|
||||
Rectangle: Cesium.Rectangle,
|
||||
ContextLimits: { maximumTextureSize },
|
||||
created,
|
||||
templates,
|
||||
};
|
||||
}
|
||||
|
||||
/** A scene with an ordered primitive collection that destroys on remove. */
|
||||
export function createShellScene() {
|
||||
const items = [];
|
||||
const listeners = new Set();
|
||||
let renders = 0;
|
||||
return {
|
||||
primitives: {
|
||||
items,
|
||||
add(primitive) {
|
||||
items.push(primitive);
|
||||
return primitive;
|
||||
},
|
||||
remove(primitive) {
|
||||
const index = items.indexOf(primitive);
|
||||
if (index < 0) return false;
|
||||
items.splice(index, 1);
|
||||
primitive.destroy();
|
||||
return true;
|
||||
},
|
||||
contains: (primitive) => items.includes(primitive),
|
||||
get: (index) => items[index],
|
||||
get length() {
|
||||
return items.length;
|
||||
},
|
||||
lowerToBottom(primitive) {
|
||||
const index = items.indexOf(primitive);
|
||||
if (index >= 0) items.unshift(...items.splice(index, 1));
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
},
|
||||
postRender: {
|
||||
addEventListener(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
emit() {
|
||||
for (const listener of [...listeners]) listener();
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
},
|
||||
requestRender() {
|
||||
renders++;
|
||||
},
|
||||
get renders() {
|
||||
return renders;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Render: finish asynchronous geometry, upload each shown material's images.
|
||||
* An image uniform back at the default binds Cesium's 1×1 texture. */
|
||||
export function renderShells(cesium, scene, count = 4) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
for (const primitive of cesium.created.primitives) {
|
||||
if (primitive.destroyed || !primitive.show) continue;
|
||||
primitive.ready = true;
|
||||
const { uniforms } = primitive.appearance.material;
|
||||
for (const name of ['image', 'detail']) {
|
||||
const dimensions = uniforms[`${name}Dimensions`];
|
||||
if (!dimensions) continue;
|
||||
const bound =
|
||||
typeof uniforms[name] === 'object'
|
||||
? uniforms[name]
|
||||
: { width: 1, height: 1 };
|
||||
dimensions.x = bound.width;
|
||||
dimensions.y = bound.height;
|
||||
}
|
||||
}
|
||||
scene.postRender.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
import { acquireWeatherImage } from './infraredImage.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES } from './source.js';
|
||||
|
||||
/** Metres above the ellipsoid. Lower shells draw first, so lightning draws last. */
|
||||
export const WEATHER_SHELL_HEIGHTS = Object.freeze({
|
||||
wind: 5_000,
|
||||
clouds: 5_500,
|
||||
'clouds-regional': 5_800,
|
||||
radar: 6_200,
|
||||
lightning: 6_600,
|
||||
});
|
||||
// Decoded canvases per renderer, full-extent and detail images alike: the shown
|
||||
// frame and the warmed next frame, each full-extent and detail (four 4096×2048
|
||||
// canvases); nothing older survives them. Cesium holds the shown canvases
|
||||
// through the material uniforms in any case.
|
||||
export const WEATHER_SHELL_CACHE_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||||
const MATERIAL_TYPE = 'WeatherFrame';
|
||||
// Renders after an image swap: one queues the upload, one uploads and draws, one spare.
|
||||
const UPLOAD_FRAMES = 3;
|
||||
// An empty detail window: the full-extent image shows everywhere.
|
||||
const NO_WINDOW = Object.freeze({ west: 0, south: 0, east: -1, north: -1 });
|
||||
// Detail windows, in degrees.
|
||||
const DETAIL_MIN_WIDTH = 6;
|
||||
const DETAIL_GRID = 0.5;
|
||||
const shellHeights = new WeakMap();
|
||||
// Cesium binds a 1×1 white texture to an image uniform until its first upload:
|
||||
// draw nothing until the full-extent image has arrived, and sample the detail
|
||||
// image only once it has arrived. The window is west, south, east, north in the
|
||||
// full-extent image's texture coordinates.
|
||||
const MATERIAL_SOURCE = `czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
vec2 st = materialInput.st;
|
||||
vec4 coarse = texture(image, st);
|
||||
vec2 dst = (st - window.xy) / max(window.zw - window.xy, vec2(1e-6));
|
||||
float inside = float(all(greaterThanEqual(st, window.xy)) && all(lessThanEqual(st, window.zw)));
|
||||
float useDetail = inside * step(1.5, float(detailDimensions.x));
|
||||
vec4 c = mix(coarse, texture(detail, clamp(dst, 0.0, 1.0)), useDetail);
|
||||
material.diffuse = czm_gammaCorrect(c.rgb);
|
||||
material.alpha = c.a * alpha * step(1.5, float(imageDimensions.x));
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
|
||||
function registerMaterial(cesium) {
|
||||
const cache = cesium.Material._materialCache;
|
||||
if (cache.getMaterial(MATERIAL_TYPE)) return;
|
||||
// A plain template: building the first material from a fabric would make that
|
||||
// instance Cesium's shared template and keep its last image alive.
|
||||
cache.addMaterial(MATERIAL_TYPE, {
|
||||
fabric: {
|
||||
type: MATERIAL_TYPE,
|
||||
uniforms: {
|
||||
image: cesium.Material.DefaultImageId,
|
||||
detail: cesium.Material.DefaultImageId,
|
||||
alpha: 1,
|
||||
window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 },
|
||||
},
|
||||
// Diffuse and alpha share these samples, so one source, not components.
|
||||
source: MATERIAL_SOURCE,
|
||||
},
|
||||
translucent: false,
|
||||
});
|
||||
}
|
||||
|
||||
const sameEdges = (a, b) =>
|
||||
a.west === b.west &&
|
||||
a.south === b.south &&
|
||||
a.east === b.east &&
|
||||
a.north === b.north;
|
||||
|
||||
/** Keep weather shells first in the primitive collection, in ascending height:
|
||||
* higher shells draw over lower ones and other opaque content draws over both. */
|
||||
export function orderWeatherShells(primitives, primitive, height) {
|
||||
shellHeights.set(primitive, height);
|
||||
if (typeof primitives.lowerToBottom !== 'function') return;
|
||||
const shells = [];
|
||||
for (let i = 0; i < primitives.length; i++) {
|
||||
const item = primitives.get(i);
|
||||
if (shellHeights.has(item)) shells.push(item);
|
||||
}
|
||||
const ordered = [...shells].sort(
|
||||
(a, b) => shellHeights.get(a) - shellHeights.get(b),
|
||||
);
|
||||
if (ordered.every((item, i) => primitives.get(i) === item)) return;
|
||||
for (const item of ordered.reverse()) primitives.lowerToBottom(item);
|
||||
}
|
||||
|
||||
/** One raised rectangle drawing one full-extent image and, inside a window of
|
||||
* it, an optional detail image. The owner calls destroy(). */
|
||||
export function createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
rectangle,
|
||||
height,
|
||||
onSettled = () => {},
|
||||
}) {
|
||||
registerMaterial(cesium);
|
||||
const scene = viewer.scene;
|
||||
const primitives = scene.primitives;
|
||||
const material = new cesium.Material({
|
||||
fabric: { type: MATERIAL_TYPE },
|
||||
translucent: false,
|
||||
});
|
||||
// Flat shading keeps the product colours independent of the viewing angle.
|
||||
const appearance = new cesium.EllipsoidSurfaceAppearance({
|
||||
aboveGround: true,
|
||||
flat: true,
|
||||
translucent: false,
|
||||
material,
|
||||
});
|
||||
// Cesium orders its translucent pass itself (weighted OIT or distance), which
|
||||
// cannot honour stacked heights. Draw in the opaque pass in scene.primitives
|
||||
// order, alpha-blended and without depth writes, so higher shells draw over
|
||||
// lower ones and nothing is hidden or picked through a shell.
|
||||
appearance.getRenderState = () => ({
|
||||
depthTest: { enabled: true },
|
||||
depthMask: false,
|
||||
blending: cesium.BlendingState.ALPHA_BLEND,
|
||||
});
|
||||
const primitive = new cesium.Primitive({
|
||||
geometryInstances: new cesium.GeometryInstance({
|
||||
geometry: new cesium.RectangleGeometry({
|
||||
rectangle,
|
||||
height,
|
||||
granularity: cesium.Math.toRadians(0.5),
|
||||
vertexFormat: cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
}),
|
||||
appearance,
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
});
|
||||
primitives.add(primitive);
|
||||
orderWeatherShells(primitives, primitive, height);
|
||||
let image = null;
|
||||
let frames = 0;
|
||||
// The detail image, its own upload count and a window waiting for it to draw.
|
||||
let detail = null;
|
||||
let detailFrames = 0;
|
||||
let pendingWindow = null;
|
||||
let offRender = null;
|
||||
let destroyed = false;
|
||||
|
||||
const fits = (size, value) =>
|
||||
!size || (size.x === value.width && size.y === value.height);
|
||||
const uploaded = () =>
|
||||
image !== null && fits(material.uniforms.imageDimensions, image);
|
||||
const detailDrawn = () =>
|
||||
detail !== null &&
|
||||
detailFrames === 0 &&
|
||||
fits(material.uniforms.detailDimensions, detail);
|
||||
const drawn = () =>
|
||||
primitive.ready &&
|
||||
uploaded() &&
|
||||
frames === 0 &&
|
||||
(detail === null || (detailDrawn() && pendingWindow === null));
|
||||
function appliedWindow() {
|
||||
const { x, y, z, w } = material.uniforms.window;
|
||||
return z < x ? null : { west: x, south: y, east: z, north: w };
|
||||
}
|
||||
function setWindow(rect) {
|
||||
const next = rect ?? NO_WINDOW;
|
||||
const { x, y, z, w } = material.uniforms.window;
|
||||
if (
|
||||
x === next.west &&
|
||||
y === next.south &&
|
||||
z === next.east &&
|
||||
w === next.north
|
||||
)
|
||||
return;
|
||||
material.uniforms.window = new cesium.Cartesian4(
|
||||
next.west,
|
||||
next.south,
|
||||
next.east,
|
||||
next.north,
|
||||
);
|
||||
scene.requestRender();
|
||||
}
|
||||
// The render governor may idle the scene; request frames only until the
|
||||
// asynchronous geometry is ready and the latest images have been drawn.
|
||||
function tick() {
|
||||
// Cesium updates a material only while its primitive is shown, so hidden
|
||||
// frames upload nothing and count for nothing.
|
||||
if (primitive.ready && primitive.show) {
|
||||
if (frames > 0) frames--;
|
||||
if (detailFrames > 0) detailFrames--;
|
||||
// A texture is never shown at another window's place.
|
||||
if (pendingWindow && detailDrawn()) {
|
||||
setWindow(pendingWindow);
|
||||
pendingWindow = null;
|
||||
}
|
||||
}
|
||||
if (primitive.show && !drawn()) {
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
offRender?.();
|
||||
offRender = null;
|
||||
if (drawn()) onSettled();
|
||||
}
|
||||
function wake() {
|
||||
if (destroyed || !primitive.show || image === null || drawn()) return;
|
||||
offRender ??= scene.postRender.addEventListener(tick);
|
||||
scene.requestRender();
|
||||
}
|
||||
|
||||
return {
|
||||
setImage(next) {
|
||||
if (destroyed || next === image) return;
|
||||
image = next;
|
||||
material.uniforms.image = next;
|
||||
frames = UPLOAD_FRAMES;
|
||||
wake();
|
||||
},
|
||||
setAlpha(value) {
|
||||
if (destroyed || material.uniforms.alpha === value) return;
|
||||
material.uniforms.alpha = value;
|
||||
scene.requestRender();
|
||||
},
|
||||
/** Draw `next` inside `rect` (west, south, east, north in the full-extent
|
||||
* image's texture coordinates, 0–1); null clears it. In the same window only
|
||||
* the image changes, and Cesium keeps drawing the previous texture until
|
||||
* this one is uploaded. A different window applies once its image has
|
||||
* drawn; until then the full-extent image covers it. */
|
||||
setDetail(next, rect) {
|
||||
if (destroyed) return;
|
||||
if (!next) {
|
||||
if (detail === null) return;
|
||||
detail = null;
|
||||
detailFrames = 0;
|
||||
pendingWindow = null;
|
||||
material.uniforms.detail = cesium.Material.DefaultImageId;
|
||||
setWindow(null);
|
||||
scene.requestRender();
|
||||
return;
|
||||
}
|
||||
if (next !== detail) {
|
||||
detail = next;
|
||||
material.uniforms.detail = next;
|
||||
detailFrames = UPLOAD_FRAMES;
|
||||
}
|
||||
const applied = appliedWindow();
|
||||
if (applied && sameEdges(applied, rect)) {
|
||||
pendingWindow = null;
|
||||
} else {
|
||||
setWindow(null);
|
||||
pendingWindow = rect;
|
||||
}
|
||||
wake();
|
||||
},
|
||||
/** Returns whether visibility changed. */
|
||||
setShow(value) {
|
||||
const show = Boolean(value);
|
||||
if (destroyed || primitive.show === show) return false;
|
||||
primitive.show = show;
|
||||
wake();
|
||||
scene.requestRender();
|
||||
return true;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
offRender?.();
|
||||
offRender = null;
|
||||
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
|
||||
primitives.remove(primitive);
|
||||
if (!primitive.isDestroyed()) primitive.destroy();
|
||||
// Primitive.destroy leaves the material and its texture alive.
|
||||
if (!material.isDestroyed()) material.destroy();
|
||||
image = null;
|
||||
detail = null;
|
||||
pendingWindow = null;
|
||||
scene.requestRender();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
height,
|
||||
ready: Boolean(primitive.ready),
|
||||
uploaded: uploaded(),
|
||||
show: primitive.show,
|
||||
alpha: material.uniforms.alpha,
|
||||
rendering: offRender !== null,
|
||||
// `uploaded` once the detail image has drawn; the window applies after.
|
||||
detail: { uploaded: detailDrawn(), window: appliedWindow() },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fitTexture(cesium, { width, height }) {
|
||||
const limit = cesium.ContextLimits?.maximumTextureSize;
|
||||
// Halve, down to 1024 px wide, on devices with a smaller texture limit.
|
||||
while (limit > 0 && width > limit && width > 1024) {
|
||||
width /= 2;
|
||||
height /= 2;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/** The detail window for a view footprint over product bounds (both in degrees;
|
||||
* a footprint across the antimeridian has west > east), or null when the
|
||||
* full-extent image is already the best available. The window is centred on
|
||||
* the footprint, max(2 × its longitude span, 6°) wide and half as tall, with
|
||||
* edges on a 0.5° grid inside the bounds. It is enabled only when narrower than
|
||||
* half the product's longitude extent and when the footprint overlaps the
|
||||
* product. `previous` is returned unchanged while the footprint centre stays in
|
||||
* its inner half and the wanted width is within 50 % of its width. */
|
||||
export function detailWindow(footprint, bounds, previous = null) {
|
||||
if (!footprint || !bounds) return null;
|
||||
const across = footprint.east < footprint.west;
|
||||
const span = footprint.east - footprint.west + (across ? 360 : 0);
|
||||
let lon = footprint.west + span / 2;
|
||||
if (lon > 180) lon -= 360;
|
||||
const lat = (footprint.south + footprint.north) / 2;
|
||||
const wanted = Math.max(2 * span, DETAIL_MIN_WIDTH);
|
||||
if (previous) {
|
||||
const width = previous.east - previous.west;
|
||||
const height = previous.north - previous.south;
|
||||
if (
|
||||
Math.abs(lon - (previous.west + previous.east) / 2) <= width / 4 &&
|
||||
Math.abs(lat - (previous.south + previous.north) / 2) <= height / 4 &&
|
||||
Math.abs(wanted - width) <= width / 2
|
||||
)
|
||||
return previous;
|
||||
}
|
||||
const inner = {
|
||||
west: Math.ceil(bounds.west / DETAIL_GRID) * DETAIL_GRID,
|
||||
south: Math.ceil(bounds.south / DETAIL_GRID) * DETAIL_GRID,
|
||||
east: Math.floor(bounds.east / DETAIL_GRID) * DETAIL_GRID,
|
||||
north: Math.floor(bounds.north / DETAIL_GRID) * DETAIL_GRID,
|
||||
};
|
||||
// Whole degrees keep both the edges and the 2:1 height on the grid; the
|
||||
// tolerance absorbs radian round trips.
|
||||
const width = Math.ceil(wanted - 1e-9);
|
||||
const height = width / 2;
|
||||
const overlaps =
|
||||
footprint.south < bounds.north &&
|
||||
footprint.north > bounds.south &&
|
||||
(across
|
||||
? footprint.west < bounds.east || footprint.east > bounds.west
|
||||
: footprint.west < bounds.east && footprint.east > bounds.west);
|
||||
if (
|
||||
!overlaps ||
|
||||
width >= (bounds.east - bounds.west) / 2 ||
|
||||
width > inner.east - inner.west ||
|
||||
height > inner.north - inner.south
|
||||
)
|
||||
return null;
|
||||
const snap = (value) => Math.round(value / DETAIL_GRID) * DETAIL_GRID;
|
||||
const west = Math.min(
|
||||
Math.max(snap(lon - width / 2), inner.west),
|
||||
inner.east - width,
|
||||
);
|
||||
const south = Math.min(
|
||||
Math.max(snap(lat - height / 2), inner.south),
|
||||
inner.north - height,
|
||||
);
|
||||
const next = { west, south, east: west + width, north: south + height };
|
||||
return previous && sameEdges(previous, next) ? previous : next;
|
||||
}
|
||||
|
||||
/** Observed weather on 3D Tiles: one raised shell per product and one
|
||||
* full-extent image per frame. The previous image stays until the next is ready.
|
||||
* Inside a window around the view the same surface samples a sharper image of
|
||||
* that area, so the two images share one mesh and never blend. */
|
||||
export function createWeatherShell({
|
||||
viewer,
|
||||
cesium,
|
||||
product,
|
||||
height = WEATHER_SHELL_HEIGHTS[product],
|
||||
getHost = () => ({ collection: null, kind: 'tileset' }),
|
||||
onChange = () => {},
|
||||
timeoutMs = 25_000,
|
||||
now = () => performance.now(),
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
decodeImage,
|
||||
createCanvas = () => document.createElement('canvas'),
|
||||
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
|
||||
}) {
|
||||
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
|
||||
throw new TypeError('Unknown weather product');
|
||||
const scene = viewer.scene;
|
||||
const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]);
|
||||
const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE);
|
||||
const infrared = product === 'clouds' || product === 'clouds-regional';
|
||||
// Global infrared contrast depends on the requested extent, so a window would
|
||||
// not match the image around it; the globe host also shows one mosaic.
|
||||
const detailed = product !== 'clouds';
|
||||
let surface = null;
|
||||
let current = null;
|
||||
let incoming = null;
|
||||
let alpha = 0.7;
|
||||
let hidden = false;
|
||||
let lastError = null;
|
||||
const images = new Map();
|
||||
let imageBytes = 0;
|
||||
let prefetchJob = null;
|
||||
let prefetchedKey = null;
|
||||
// The detail window for the shown bounds, the image drawn in it and its fetch.
|
||||
let view = null;
|
||||
let viewBounds = null;
|
||||
let detail = null;
|
||||
let detailJob = null;
|
||||
let offCamera = null;
|
||||
|
||||
const suspended = () => imageryHostStatus(getHost()) !== null;
|
||||
const visible = () => !hidden && !suspended();
|
||||
const boundsKey = ({ west, south, east, north }) =>
|
||||
`${west},${south},${east},${north}`;
|
||||
const cacheKey = (time, mode, box = null) =>
|
||||
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
|
||||
const wantedDetail = () =>
|
||||
current && view ? cacheKey(current.time, current.infrared, view) : null;
|
||||
|
||||
function evict(keep = []) {
|
||||
const kept = new Set([...keep, current?.key, detail?.key]);
|
||||
for (const [key, entry] of images) {
|
||||
if (imageBytes <= cacheBytes) return;
|
||||
if (kept.has(key)) continue;
|
||||
images.delete(key);
|
||||
imageBytes -= entry.bytes;
|
||||
}
|
||||
}
|
||||
async function acquire(
|
||||
time,
|
||||
mode,
|
||||
signal,
|
||||
{ box = null, keep = [], onFetched } = {},
|
||||
) {
|
||||
signal.throwIfAborted();
|
||||
const key = cacheKey(time, mode, box);
|
||||
const entry = images.get(key);
|
||||
if (entry) {
|
||||
images.delete(key);
|
||||
images.set(key, entry);
|
||||
return { texture: entry.image, decodeMs: 0, cached: true };
|
||||
}
|
||||
const result = await acquireWeatherImage(product, time, {
|
||||
signal,
|
||||
mode,
|
||||
size: box ? detailSize : size,
|
||||
bbox: box,
|
||||
maxBytes: MAX_IMAGE_BYTES,
|
||||
createCanvas,
|
||||
fetchImpl,
|
||||
decodeImage,
|
||||
now,
|
||||
onFetched,
|
||||
});
|
||||
// An aborted decode may still finish; never repopulate a cleared instance.
|
||||
signal.throwIfAborted();
|
||||
const previous = images.get(key);
|
||||
if (previous) imageBytes -= previous.bytes;
|
||||
images.delete(key);
|
||||
const bytes = result.texture.width * result.texture.height * 4;
|
||||
images.set(key, { image: result.texture, bytes });
|
||||
imageBytes += bytes;
|
||||
evict([key, ...keep]);
|
||||
return { ...result, cached: false };
|
||||
}
|
||||
/** The detail box in the full-extent image's texture coordinates. */
|
||||
function detailRect() {
|
||||
const b = current.extent;
|
||||
const w = detail.box;
|
||||
const x = b.east - b.west;
|
||||
const y = b.north - b.south;
|
||||
return {
|
||||
west: (w.west - b.west) / x,
|
||||
south: (w.south - b.south) / y,
|
||||
east: (w.east - b.west) / x,
|
||||
north: (w.north - b.south) / y,
|
||||
};
|
||||
}
|
||||
function syncDetail() {
|
||||
if (detail && current) surface?.setDetail(detail.image, detailRect());
|
||||
else surface?.setDetail(null);
|
||||
}
|
||||
function applyVisibility() {
|
||||
return surface?.setShow(visible()) ?? false;
|
||||
}
|
||||
function cancelDetail() {
|
||||
clearTimeout(detailJob?.timeout);
|
||||
detailJob?.controller.abort();
|
||||
detailJob = null;
|
||||
}
|
||||
function dropDetail() {
|
||||
detail = null;
|
||||
// The full-extent image covers the window in the same turn.
|
||||
surface?.setDetail(null);
|
||||
}
|
||||
function installDetail(job, image) {
|
||||
if (detailJob !== job) return;
|
||||
clearTimeout(job.timeout);
|
||||
detailJob = null;
|
||||
detail = { box: job.box, key: job.key, image, stale: false };
|
||||
syncDetail();
|
||||
evict();
|
||||
scene.requestRender();
|
||||
}
|
||||
function failDetail(job) {
|
||||
if (detailJob !== job) return;
|
||||
clearTimeout(job.timeout);
|
||||
detailJob = null;
|
||||
if (detail?.stale) dropDetail();
|
||||
scene.requestRender();
|
||||
}
|
||||
/** Bring the detail to the shown frame and window. The previous detail image
|
||||
* stays until the next is decoded unless the window itself moved, and over at
|
||||
* most one newer frame (see commit). */
|
||||
function refreshDetail() {
|
||||
const key = wantedDetail();
|
||||
if (!key) {
|
||||
cancelDetail();
|
||||
dropDetail();
|
||||
return;
|
||||
}
|
||||
if (detail && boundsKey(detail.box) !== boundsKey(view)) dropDetail();
|
||||
syncDetail();
|
||||
if (detail?.key === key) {
|
||||
detail.stale = false;
|
||||
cancelDetail();
|
||||
return;
|
||||
}
|
||||
// Another frame's detail: the next commit drops it unless replaced first.
|
||||
if (detail) detail.stale = true;
|
||||
if (!visible()) {
|
||||
cancelDetail();
|
||||
return;
|
||||
}
|
||||
if (detailJob?.key === key) return;
|
||||
cancelDetail();
|
||||
const job = { key, box: view, controller: new AbortController() };
|
||||
detailJob = job;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
void acquire(current.time, current.infrared, job.controller.signal, {
|
||||
box: view,
|
||||
})
|
||||
.then(({ texture }) => installDetail(job, texture))
|
||||
.catch(() => failDetail(job));
|
||||
}
|
||||
/** Recompute the window from the camera's ground footprint; returns whether it changed. */
|
||||
function computeView() {
|
||||
const rectangle = detailed
|
||||
? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid)
|
||||
: null;
|
||||
const degrees = cesium.Math.toDegrees;
|
||||
const footprint = rectangle && {
|
||||
west: degrees(rectangle.west),
|
||||
south: degrees(rectangle.south),
|
||||
east: degrees(rectangle.east),
|
||||
north: degrees(rectangle.north),
|
||||
};
|
||||
const next = detailWindow(
|
||||
footprint,
|
||||
current.extent,
|
||||
viewBounds === current.bounds ? view : null,
|
||||
);
|
||||
viewBounds = current.bounds;
|
||||
if (next === view) return false;
|
||||
view = next;
|
||||
return true;
|
||||
}
|
||||
function watchCamera() {
|
||||
if (offCamera || !detailed) return;
|
||||
offCamera =
|
||||
viewer.camera?.moveEnd?.addEventListener(() => {
|
||||
if (current && computeView()) refreshDetail();
|
||||
}) ?? null;
|
||||
}
|
||||
function cancelPrefetch() {
|
||||
clearTimeout(prefetchJob?.timeout);
|
||||
prefetchJob?.controller.abort();
|
||||
prefetchJob = null;
|
||||
prefetchedKey = null;
|
||||
}
|
||||
function watch(frame, signal) {
|
||||
if (!signal) return;
|
||||
frame.owned = true;
|
||||
const abort = () => {
|
||||
if (incoming === frame) cancelIncoming();
|
||||
scene.requestRender();
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
frame.offAbort = () => signal.removeEventListener('abort', abort);
|
||||
}
|
||||
function close(frame) {
|
||||
frame.closed = true;
|
||||
frame.controller.abort();
|
||||
clearTimeout(frame.timeout);
|
||||
frame.offAbort?.();
|
||||
frame.surface?.destroy();
|
||||
frame.surface = null;
|
||||
}
|
||||
function cancelIncoming() {
|
||||
if (!incoming) return;
|
||||
const frame = incoming;
|
||||
incoming = null;
|
||||
close(frame);
|
||||
frame.resolve(false);
|
||||
}
|
||||
function fail(frame) {
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
close(frame);
|
||||
lastError = 'Weather tiles unavailable · previous frame retained';
|
||||
frame.resolve(false);
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
}
|
||||
function commit(frame) {
|
||||
if (incoming !== frame) return;
|
||||
incoming = null;
|
||||
clearTimeout(frame.timeout);
|
||||
frame.offAbort?.();
|
||||
if (frame.surface) {
|
||||
surface?.destroy();
|
||||
surface = frame.surface;
|
||||
frame.surface = null;
|
||||
}
|
||||
const { west, south, east, north } = frame.snapshot.bounds;
|
||||
current = {
|
||||
time: frame.time,
|
||||
product: frame.product,
|
||||
infrared: frame.infrared,
|
||||
bounds: frame.bounds,
|
||||
extent: { west, south, east, north },
|
||||
key: frame.key,
|
||||
mosaic: frame.mosaic,
|
||||
loadMs: now() - frame.startedAt,
|
||||
};
|
||||
surface.setAlpha(alpha);
|
||||
applyVisibility();
|
||||
lastError = null;
|
||||
evict();
|
||||
// The full-extent image swaps first; the detail follows for the same time.
|
||||
watchCamera();
|
||||
if (viewBounds !== current.bounds) computeView();
|
||||
// An older frame's detail stays over at most one newer frame.
|
||||
if (detail?.stale && detail.key !== wantedDetail()) dropDetail();
|
||||
refreshDetail();
|
||||
frame.resolve(true);
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
}
|
||||
function install(frame, image) {
|
||||
if (frame.closed || incoming !== frame) return;
|
||||
if (surface?.bounds === frame.bounds) {
|
||||
// Cesium keeps drawing the previous texture until this one is uploaded.
|
||||
surface.setImage(image);
|
||||
commit(frame);
|
||||
return;
|
||||
}
|
||||
// First frame or a new extent: stage an invisible surface and keep the
|
||||
// previous one until the new geometry and texture are drawable.
|
||||
const { west, south, east, north } = frame.snapshot.bounds;
|
||||
const next = createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
height,
|
||||
rectangle: cesium.Rectangle.fromDegrees(west, south, east, north),
|
||||
onSettled: () => commit(frame),
|
||||
});
|
||||
next.bounds = frame.bounds;
|
||||
frame.surface = next;
|
||||
next.setAlpha(0);
|
||||
next.setImage(image);
|
||||
}
|
||||
|
||||
return {
|
||||
product,
|
||||
rehome() {
|
||||
if (suspended()) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
cancelDetail();
|
||||
}
|
||||
const changed = applyVisibility();
|
||||
if (current && !suspended()) {
|
||||
computeView();
|
||||
refreshDetail();
|
||||
}
|
||||
if (changed) scene.requestRender();
|
||||
return changed;
|
||||
},
|
||||
cancelPrefetch,
|
||||
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
|
||||
let job;
|
||||
try {
|
||||
if (
|
||||
hidden ||
|
||||
incoming ||
|
||||
suspended() ||
|
||||
snapshot.product !== product ||
|
||||
!snapshot.times.includes(time)
|
||||
)
|
||||
return false;
|
||||
// Warm the next frame's detail window too while the window stays put.
|
||||
const box =
|
||||
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
|
||||
const coarse = cacheKey(time, mode);
|
||||
const key = box ? cacheKey(time, mode, box) : coarse;
|
||||
if (prefetchJob?.key === key || prefetchedKey === key) return false;
|
||||
cancelPrefetch();
|
||||
job = { key, controller: new AbortController() };
|
||||
prefetchJob = job;
|
||||
const { signal } = job.controller;
|
||||
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
|
||||
// Both images at once: one after the other would outlast playback's step.
|
||||
await Promise.all([
|
||||
acquire(time, mode, signal, { keep: box ? [key] : [] }),
|
||||
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
if (prefetchJob === job) prefetchedKey = key;
|
||||
return true;
|
||||
} catch {
|
||||
job?.controller.abort();
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(job?.timeout);
|
||||
if (job && prefetchJob === job) prefetchJob = null;
|
||||
}
|
||||
},
|
||||
async setFrame(
|
||||
snapshot,
|
||||
time,
|
||||
{ signal, infrared: mode = 'filtered' } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
cancelPrefetch();
|
||||
const bounds = boundsKey(snapshot.bounds);
|
||||
const same = (frame) =>
|
||||
frame?.time === time &&
|
||||
frame.product === snapshot.product &&
|
||||
frame.infrared === mode &&
|
||||
frame.bounds === bounds;
|
||||
// A request for the frame a host switch is restaging joins that work.
|
||||
if (same(incoming) && !incoming.owned && !suspended()) {
|
||||
watch(incoming, signal);
|
||||
return incoming.result;
|
||||
}
|
||||
cancelIncoming();
|
||||
applyVisibility();
|
||||
if (suspended()) return false;
|
||||
if (same(current)) return true;
|
||||
lastError = null;
|
||||
const frame = {
|
||||
snapshot,
|
||||
time,
|
||||
infrared: mode,
|
||||
bounds,
|
||||
product: snapshot.product,
|
||||
key: cacheKey(time, mode),
|
||||
mosaic: { fetched: false, decodeMs: null, cached: false },
|
||||
controller: new AbortController(),
|
||||
startedAt: now(),
|
||||
surface: null,
|
||||
owned: false,
|
||||
closed: false,
|
||||
resolve: null,
|
||||
};
|
||||
incoming = frame;
|
||||
frame.result = new Promise((resolve) => {
|
||||
frame.resolve = resolve;
|
||||
});
|
||||
watch(frame, signal);
|
||||
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
|
||||
void acquire(time, mode, frame.controller.signal, {
|
||||
onFetched: () => {
|
||||
frame.mosaic.fetched = true;
|
||||
},
|
||||
})
|
||||
.then(({ texture, decodeMs, cached }) => {
|
||||
frame.mosaic.decodeMs = decodeMs;
|
||||
frame.mosaic.cached = cached;
|
||||
install(frame, texture);
|
||||
})
|
||||
.catch(() => fail(frame));
|
||||
scene.requestRender();
|
||||
onChange();
|
||||
return frame.result;
|
||||
},
|
||||
setHidden(value) {
|
||||
hidden = Boolean(value);
|
||||
if (hidden) {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
cancelDetail();
|
||||
}
|
||||
applyVisibility();
|
||||
if (!hidden) refreshDetail();
|
||||
scene.requestRender();
|
||||
},
|
||||
setAlpha(value) {
|
||||
alpha = value;
|
||||
surface?.setAlpha(alpha);
|
||||
scene.requestRender();
|
||||
},
|
||||
clear() {
|
||||
cancelPrefetch();
|
||||
cancelIncoming();
|
||||
cancelDetail();
|
||||
offCamera?.();
|
||||
offCamera = null;
|
||||
dropDetail();
|
||||
surface?.destroy();
|
||||
surface = null;
|
||||
current = null;
|
||||
view = null;
|
||||
viewBounds = null;
|
||||
hidden = false;
|
||||
lastError = null;
|
||||
images.clear();
|
||||
imageBytes = 0;
|
||||
scene.requestRender();
|
||||
},
|
||||
getDiagnostics() {
|
||||
const drawn = surface?.getDiagnostics();
|
||||
const applied = drawn?.detail.window;
|
||||
return {
|
||||
host: 'shell',
|
||||
height,
|
||||
imageSize: { ...size },
|
||||
shell: drawn
|
||||
? {
|
||||
...drawn,
|
||||
detail: {
|
||||
bbox: view
|
||||
? [view.west, view.south, view.east, view.north]
|
||||
: null,
|
||||
size: { ...detailSize },
|
||||
// The wanted window is applied and its image drawn.
|
||||
ready: Boolean(
|
||||
detail &&
|
||||
current &&
|
||||
detail.key === wantedDetail() &&
|
||||
drawn.ready &&
|
||||
drawn.show &&
|
||||
drawn.detail.uploaded &&
|
||||
applied &&
|
||||
sameEdges(applied, detailRect()),
|
||||
),
|
||||
enabled: view !== null,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
cache: {
|
||||
mosaics: images.size,
|
||||
bytes: imageBytes,
|
||||
prefetching: !!prefetchJob,
|
||||
},
|
||||
imageryCount: Number(!!surface) + Number(!!incoming?.surface),
|
||||
mosaic: (incoming || current)?.mosaic,
|
||||
infrared: (incoming || current)?.infrared ?? 'filtered',
|
||||
loading: !!incoming,
|
||||
time: hidden ? null : (current?.time ?? null),
|
||||
hidden,
|
||||
product: current?.product ?? null,
|
||||
frameLoadMs: current?.loadMs ?? null,
|
||||
error: imageryHostStatus(getHost()) || lastError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,136 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
export const WEATHER_PRODUCTS = Object.freeze([
|
||||
'radar',
|
||||
'clouds',
|
||||
'clouds-regional',
|
||||
'lightning',
|
||||
]);
|
||||
|
||||
/** Only bounded, explicit observations may become imagery requests. */
|
||||
export function validateWeatherSnapshot(value, product) {
|
||||
if (!value || value.schemaVersion !== 1 || value.product !== product)
|
||||
throw new Error('Malformed weather manifest');
|
||||
if (value.unavailable) return value;
|
||||
const { bounds, times } = value;
|
||||
if (
|
||||
!bounds ||
|
||||
!['west', 'south', 'east', 'north'].every((key) =>
|
||||
Number.isFinite(bounds[key]),
|
||||
) ||
|
||||
bounds.west < -180 ||
|
||||
bounds.east > 180 ||
|
||||
bounds.south < -90 ||
|
||||
bounds.north > 90 ||
|
||||
bounds.west >= bounds.east ||
|
||||
bounds.south >= bounds.north ||
|
||||
!Array.isArray(times) ||
|
||||
times.length < 1 ||
|
||||
times.length > 13 ||
|
||||
times.some(
|
||||
(time, i) =>
|
||||
typeof time !== 'string' ||
|
||||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(time) ||
|
||||
!Number.isFinite(Date.parse(time)) ||
|
||||
new Date(time).toISOString() !== time ||
|
||||
(i > 0 && time <= times[i - 1]),
|
||||
) ||
|
||||
value.latest !== times.at(-1) ||
|
||||
value.tileSize !== 256 ||
|
||||
value.maxLevel !== 6 ||
|
||||
value.tilingScheme !== 'geographic'
|
||||
)
|
||||
throw new Error('Malformed weather manifest');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Largest whole-extent image per product; also the proxy default size. */
|
||||
export const WEATHER_IMAGE_SIZES = Object.freeze({
|
||||
radar: Object.freeze({ width: 4096, height: 2048 }),
|
||||
'clouds-regional': Object.freeze({ width: 4096, height: 2048 }),
|
||||
clouds: Object.freeze({ width: 2048, height: 1024 }),
|
||||
lightning: Object.freeze({ width: 4096, height: 2048 }),
|
||||
});
|
||||
/** Largest detail-window image for every product; also the proxy default. */
|
||||
export const WEATHER_DETAIL_SIZE = Object.freeze({ width: 4096, height: 2048 });
|
||||
|
||||
/** A whole-extent image, or with `bbox` ({ west, south, east, north } degrees)
|
||||
* a detail window of the same product. */
|
||||
export function weatherImageUrl(
|
||||
product,
|
||||
time,
|
||||
{ width, height } = {},
|
||||
bbox = null,
|
||||
) {
|
||||
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
|
||||
throw new Error('Invalid weather frame');
|
||||
let box = '';
|
||||
if (bbox !== null) {
|
||||
const edges = [bbox.west, bbox.south, bbox.east, bbox.north];
|
||||
if (
|
||||
!edges.every(Number.isFinite) ||
|
||||
edges[0] >= edges[2] ||
|
||||
edges[1] >= edges[3]
|
||||
)
|
||||
throw new Error('Invalid weather window');
|
||||
box = `&bbox=${edges.join(',')}`;
|
||||
}
|
||||
const largest =
|
||||
bbox === null ? WEATHER_IMAGE_SIZES[product] : WEATHER_DETAIL_SIZE;
|
||||
let size = '';
|
||||
if (width !== undefined || height !== undefined) {
|
||||
if (
|
||||
![1024, 2048, 4096].includes(width) ||
|
||||
height !== width / 2 ||
|
||||
width > largest.width
|
||||
)
|
||||
throw new Error('Invalid weather image size');
|
||||
// The largest size is the proxy default: one frame has one URL.
|
||||
if (width !== largest.width) size = `&size=${width}x${height}`;
|
||||
}
|
||||
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${box}${size}`;
|
||||
}
|
||||
|
||||
export function weatherTileUrl(product, time, { size } = {}) {
|
||||
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
|
||||
throw new Error('Invalid weather frame');
|
||||
if (size !== undefined && ![256, 512, 1024].includes(size))
|
||||
throw new Error('Invalid weather tile size');
|
||||
// Construct locally; never accept a manifest-provided host or template.
|
||||
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
|
||||
}
|
||||
|
||||
/** Acquisition is lazy and shares the application's existing source contract. */
|
||||
export function createWeatherSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 15_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ product = 'radar', signal } = {}) {
|
||||
if (!WEATHER_PRODUCTS.includes(product))
|
||||
throw new Error('Unknown weather product');
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Weather request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/weather/manifest?product=${product}`,
|
||||
{ signal: controller.signal, cache: 'no-store', redirect: 'error' },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
|
||||
return validateWeatherSnapshot(
|
||||
await readResponseJsonCapped(response, 16_384, controller.signal),
|
||||
product,
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createWeatherSource,
|
||||
validateWeatherSnapshot,
|
||||
weatherImageUrl,
|
||||
weatherTileUrl,
|
||||
WEATHER_DETAIL_SIZE,
|
||||
WEATHER_IMAGE_SIZES,
|
||||
} from './source.js';
|
||||
const time = '2026-09-16T02:00:00.000Z';
|
||||
const snapshot = () => ({
|
||||
schemaVersion: 1,
|
||||
product: 'radar',
|
||||
bounds: { west: -130, south: 20, east: -60, north: 55 },
|
||||
times: [time],
|
||||
latest: time,
|
||||
tileSize: 256,
|
||||
maxLevel: 6,
|
||||
tilingScheme: 'geographic',
|
||||
});
|
||||
test('observed source accepts bounded exact times and refuses malformed or unsorted frames', () => {
|
||||
assert.equal(validateWeatherSnapshot(snapshot(), 'radar').latest, time);
|
||||
for (const bad of [
|
||||
{ times: [time, time] },
|
||||
{ product: 'other' },
|
||||
{ times: Array(14).fill(time) },
|
||||
{ latest: 'tomorrow' },
|
||||
{ maxLevel: 20 },
|
||||
{ bounds: { west: -999, south: 0, east: 1, north: 1 } },
|
||||
])
|
||||
assert.throws(
|
||||
() => validateWeatherSnapshot({ ...snapshot(), ...bad }, 'radar'),
|
||||
/Malformed/,
|
||||
);
|
||||
});
|
||||
test('weather tile URLs are same origin and ignore upstream templates', async () => {
|
||||
const calls = [];
|
||||
const source = createWeatherSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return Response.json({
|
||||
...snapshot(),
|
||||
tileTemplate: 'https://example.invalid/{z}',
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await source.getSnapshot({ product: 'radar' });
|
||||
assert.match(
|
||||
weatherTileUrl(result.product, result.latest),
|
||||
/^\/api\/weather\/tile\?product=radar&time=2026/,
|
||||
);
|
||||
assert.equal(calls[0].options.redirect, 'error');
|
||||
assert.equal(calls[0].url, '/api/weather/manifest?product=radar');
|
||||
});
|
||||
test('source aborts before acquisition and caps streamed manifest bytes', async () => {
|
||||
let calls = 0;
|
||||
const source = createWeatherSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return new Response(' '.repeat(17000));
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(source.getSnapshot(), /too large/);
|
||||
});
|
||||
|
||||
test('weather tile URLs carry only supported optional pixel sizes', () => {
|
||||
assert.equal(
|
||||
new URL(
|
||||
weatherTileUrl('radar', time),
|
||||
'https://example.test',
|
||||
).searchParams.has('size'),
|
||||
false,
|
||||
);
|
||||
for (const size of [256, 512, 1024]) {
|
||||
const url = new URL(
|
||||
weatherTileUrl('lightning', time, { size }),
|
||||
'https://example.test',
|
||||
);
|
||||
assert.equal(url.searchParams.get('size'), String(size));
|
||||
assert.equal(url.searchParams.get('time'), time);
|
||||
}
|
||||
for (const size of [0, 257, 2048, '1024', null])
|
||||
assert.throws(() => weatherTileUrl('radar', time, { size }), /tile size/);
|
||||
});
|
||||
|
||||
test('whole-extent image URLs are same origin and omit the default largest size', () => {
|
||||
for (const [product, width] of [
|
||||
['radar', 4096],
|
||||
['clouds-regional', 4096],
|
||||
['clouds', 2048],
|
||||
['lightning', 4096],
|
||||
]) {
|
||||
assert.deepEqual(WEATHER_IMAGE_SIZES[product], {
|
||||
width,
|
||||
height: width / 2,
|
||||
});
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time),
|
||||
`/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`,
|
||||
);
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time, { width, height: width / 2 }),
|
||||
weatherImageUrl(product, time),
|
||||
);
|
||||
assert.equal(
|
||||
new URL(
|
||||
weatherImageUrl(product, time, { width: 1024, height: 512 }),
|
||||
'https://example.test',
|
||||
).searchParams.get('size'),
|
||||
'1024x512',
|
||||
);
|
||||
}
|
||||
for (const size of [
|
||||
{ width: 8192, height: 4096 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 512, height: 256 },
|
||||
{ width: '1024', height: 512 },
|
||||
])
|
||||
assert.throws(() => weatherImageUrl('lightning', time, size), /size/);
|
||||
assert.throws(
|
||||
() => weatherImageUrl('clouds', time, { width: 4096, height: 2048 }),
|
||||
/size/,
|
||||
);
|
||||
assert.throws(() => weatherImageUrl('other', time), /frame/);
|
||||
assert.throws(() => weatherImageUrl('radar', 'latest'), /frame/);
|
||||
});
|
||||
|
||||
test('detail-window image URLs carry the bbox and omit the default detail size', () => {
|
||||
const box = { west: -102, south: 34, east: -96, north: 37 };
|
||||
assert.deepEqual(WEATHER_DETAIL_SIZE, { width: 4096, height: 2048 });
|
||||
for (const product of ['radar', 'clouds-regional', 'lightning', 'clouds']) {
|
||||
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=-102,34,-96,37`;
|
||||
assert.equal(weatherImageUrl(product, time, {}, box), url);
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time, { width: 4096, height: 2048 }, box),
|
||||
url,
|
||||
'every product has a 4096×2048 detail default',
|
||||
);
|
||||
assert.equal(
|
||||
weatherImageUrl(product, time, { width: 2048, height: 1024 }, box),
|
||||
`${url}&size=2048x1024`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
weatherImageUrl('radar', time, {}, { ...box, west: -97.5, east: -91.5 }),
|
||||
`/api/weather/image?product=radar&time=${encodeURIComponent(time)}&bbox=-97.5,34,-91.5,37`,
|
||||
);
|
||||
for (const bad of [
|
||||
{ ...box, west: -96 },
|
||||
{ ...box, north: 34 },
|
||||
{ ...box, east: Number.NaN },
|
||||
{ west: -102, south: 34, east: -96 },
|
||||
])
|
||||
assert.throws(() => weatherImageUrl('radar', time, {}, bad), /window/);
|
||||
assert.throws(
|
||||
() => weatherImageUrl('radar', time, { width: 8192, height: 4096 }, box),
|
||||
/size/,
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
import { normalizeLongitude, sampleWind } from './model.js';
|
||||
|
||||
/** Fixed physical scales shared by imagery and the field legend. */
|
||||
export const WIND_FIELDS = Object.freeze({
|
||||
speed: Object.freeze({
|
||||
label: 'Wind speed',
|
||||
units: 'm/s',
|
||||
min: 0,
|
||||
max: 30,
|
||||
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
|
||||
}),
|
||||
temperature: Object.freeze({
|
||||
label: '2 m air temperature',
|
||||
units: '°C',
|
||||
min: -40,
|
||||
max: 50,
|
||||
// Fixed 10°C anchors keep model comparisons honest while making everyday
|
||||
// temperature gradients distinguishable from the underlying basemap.
|
||||
stops: [
|
||||
'#31235b',
|
||||
'#403b92',
|
||||
'#315fc1',
|
||||
'#268fce',
|
||||
'#31bdd0',
|
||||
'#8ad4aa',
|
||||
'#f0d255',
|
||||
'#ee8a34',
|
||||
'#d74638',
|
||||
'#9f274d',
|
||||
],
|
||||
}),
|
||||
pressure: Object.freeze({
|
||||
label: 'Mean sea-level pressure',
|
||||
units: 'hPa',
|
||||
min: 960,
|
||||
max: 1050,
|
||||
stops: ['#705ca0', '#6985b4', '#a5c3cd', '#d8c99a', '#dbad61'],
|
||||
}),
|
||||
});
|
||||
|
||||
function palette(stops) {
|
||||
const rgb = stops.map((color) =>
|
||||
color.match(/\w\w/g).map((value) => parseInt(value, 16)),
|
||||
);
|
||||
return Array.from({ length: 128 }, (_, i) => {
|
||||
const p = (i / 127) * (rgb.length - 1);
|
||||
const a = Math.min(rgb.length - 2, Math.floor(p));
|
||||
return rgb[a].map((value, channel) =>
|
||||
Math.round(value + (rgb[a + 1][channel] - value) * (p - a)),
|
||||
);
|
||||
});
|
||||
}
|
||||
const PALETTES = Object.fromEntries(
|
||||
Object.entries(WIND_FIELDS).map(([kind, spec]) => [
|
||||
kind,
|
||||
palette(spec.stops),
|
||||
]),
|
||||
);
|
||||
const WIND_COLORS = PALETTES.speed.map((rgb) => `rgb(${rgb.join(',')})`);
|
||||
const clamp = (x, low, high) => Math.max(low, Math.min(high, x));
|
||||
|
||||
/** No parsing, arrays or CSS interpolation in the particle loop. */
|
||||
export function windTrailColor(speed) {
|
||||
return WIND_COLORS[
|
||||
Math.round((clamp(Number.isFinite(speed) ? speed : 0, 0, 30) / 30) * 127)
|
||||
];
|
||||
}
|
||||
|
||||
/** Decay is a function of elapsed time, independent of refresh rate. */
|
||||
export function trailEraseAlpha(seconds, halfLife = 0.65) {
|
||||
return 1 - Math.pow(0.5, Math.max(0, seconds) / halfLife);
|
||||
}
|
||||
|
||||
/** Scalar units are already normalized by the source; do not convert twice. */
|
||||
export function sampleScalar(
|
||||
snapshot,
|
||||
lon,
|
||||
lat,
|
||||
kind = snapshot?.scalar?.kind ?? 'speed',
|
||||
) {
|
||||
const grid = snapshot?.grid ?? snapshot;
|
||||
if (!grid || !Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
if (kind === 'speed') {
|
||||
const wind = sampleWind(
|
||||
grid === snapshot ? grid : { ...grid, u: snapshot.u, v: snapshot.v },
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const speed = Math.hypot(wind.u, wind.v);
|
||||
return Number.isFinite(speed) ? speed : null;
|
||||
}
|
||||
const scalar = snapshot.scalar;
|
||||
if (
|
||||
!WIND_FIELDS[kind] ||
|
||||
scalar?.kind !== kind ||
|
||||
scalar.units !== WIND_FIELDS[kind].units ||
|
||||
!scalar.values
|
||||
)
|
||||
return null;
|
||||
const { nx, ny, lo1, la1, dx, dy } = grid;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = clamp((la1 - lat) / dy, 0, ny - 1);
|
||||
const x0 = Math.floor(x);
|
||||
const y0 = Math.floor(y);
|
||||
const tx = x - x0;
|
||||
const ty = y - y0;
|
||||
const col = (n) => ((n % nx) + nx) % nx;
|
||||
const y1 = Math.min(y0 + 1, ny - 1);
|
||||
const a = scalar.values[y0 * nx + col(x0)];
|
||||
const b = scalar.values[y0 * nx + col(x0 + 1)];
|
||||
const c = scalar.values[y1 * nx + col(x0)];
|
||||
const d = scalar.values[y1 * nx + col(x0 + 1)];
|
||||
const value =
|
||||
(a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** One bounded geographic texture per installed field, never per render frame. */
|
||||
export function createFieldRaster(snapshot, kind, width = 360, height = 181) {
|
||||
const spec = WIND_FIELDS[kind];
|
||||
if (
|
||||
!spec ||
|
||||
!snapshot ||
|
||||
!Number.isInteger(width) ||
|
||||
!Number.isInteger(height) ||
|
||||
width < 2 ||
|
||||
height < 2 ||
|
||||
width > 720 ||
|
||||
height > 362
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
kind !== 'speed' &&
|
||||
(snapshot.scalar?.kind !== kind || snapshot.scalar.units !== spec.units)
|
||||
)
|
||||
return null;
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
const flat = snapshot.grid
|
||||
? {
|
||||
...snapshot.grid,
|
||||
u: snapshot.u,
|
||||
v: snapshot.v,
|
||||
scalar: snapshot.scalar,
|
||||
}
|
||||
: snapshot;
|
||||
let finiteCount = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
const lat = 90 - ((y + 0.5) / height) * 180;
|
||||
for (let x = 0; x < width; x++) {
|
||||
// Pixel centers cover -180..180; the source's 0-degree origin is not an image origin.
|
||||
const lon = -180 + ((x + 0.5) / width) * 360;
|
||||
const value = sampleScalar(flat, lon, lat, kind);
|
||||
if (value === null) continue;
|
||||
const t = clamp((value - spec.min) / (spec.max - spec.min), 0, 1);
|
||||
const rgb = PALETTES[kind][Math.round(t * 127)];
|
||||
const offset = (y * width + x) * 4;
|
||||
rgba[offset] = rgb[0];
|
||||
rgba[offset + 1] = rgb[1];
|
||||
rgba[offset + 2] = rgb[2];
|
||||
const alpha =
|
||||
kind === 'speed'
|
||||
? 0.22 + 0.43 * Math.sqrt(t)
|
||||
: kind === 'pressure'
|
||||
? 0.24 + 0.3 * Math.min(1, Math.abs(value - 1013) / 40)
|
||||
: 0.95;
|
||||
rgba[offset + 3] = Math.round(255 * alpha);
|
||||
finiteCount++;
|
||||
}
|
||||
}
|
||||
return finiteCount ? { width, height, rgba } : null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
WIND_FIELDS,
|
||||
createFieldRaster,
|
||||
sampleScalar,
|
||||
trailEraseAlpha,
|
||||
windTrailColor,
|
||||
} from './fields.js';
|
||||
import { sampleWind } from './model.js';
|
||||
|
||||
const snapshot = {
|
||||
grid: { nx: 4, ny: 2, lo1: 0, la1: 90, dx: 90, dy: 180 },
|
||||
u: new Float32Array(8).fill(3),
|
||||
v: new Float32Array(8).fill(4),
|
||||
scalar: {
|
||||
kind: 'temperature',
|
||||
units: '°C',
|
||||
values: new Float32Array([0, 10, 20, 30, -10, 0, 10, 20]),
|
||||
},
|
||||
};
|
||||
|
||||
test('scalar samples preserve normalized units, geometry, poles and the date-line seam', () => {
|
||||
assert.equal(sampleScalar(snapshot, 0, 90, 'speed'), 5);
|
||||
assert.equal(sampleScalar(snapshot, 0, 90, 'temperature'), 0);
|
||||
assert.equal(sampleScalar(snapshot, 90, -90, 'temperature'), 0);
|
||||
assert.equal(sampleScalar(snapshot, 180, 0, 'temperature'), 15);
|
||||
assert.equal(sampleScalar(snapshot, -180, 0, 'temperature'), 15);
|
||||
assert.equal(
|
||||
sampleScalar(snapshot, -0.01, 90, 'temperature'),
|
||||
sampleScalar(snapshot, 359.99, 90, 'temperature'),
|
||||
);
|
||||
assert.equal(sampleScalar(snapshot, 0, 0, 'pressure'), null);
|
||||
assert.equal(
|
||||
sampleScalar(
|
||||
{ ...snapshot, scalar: { ...snapshot.scalar, units: 'K' } },
|
||||
0,
|
||||
0,
|
||||
'temperature',
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(sampleScalar(snapshot, Infinity, 0, 'speed'), null);
|
||||
});
|
||||
|
||||
test('raster samples geographic pixel centers, not the source zero-longitude origin', () => {
|
||||
const raster = createFieldRaster(snapshot, 'temperature', 4, 2);
|
||||
const shifted = {
|
||||
...snapshot,
|
||||
grid: { ...snapshot.grid, lo1: -180 },
|
||||
scalar: {
|
||||
...snapshot.scalar,
|
||||
values: new Float32Array([20, 30, 0, 10, 10, 20, -10, 0]),
|
||||
},
|
||||
};
|
||||
assert.deepEqual(raster, createFieldRaster(shifted, 'temperature', 4, 2));
|
||||
assert.equal(raster.rgba.length, 32);
|
||||
assert.notDeepEqual(
|
||||
[...raster.rgba.slice(0, 3)],
|
||||
[...raster.rgba.slice(8, 11)],
|
||||
);
|
||||
assert.equal(createFieldRaster(snapshot, 'pressure'), null);
|
||||
assert.equal(createFieldRaster(snapshot, 'speed', 4000, 2000), null);
|
||||
});
|
||||
|
||||
test('trail decay is independent of frame cadence and palette lookup clamps endpoints', () => {
|
||||
const remaining30 = (1 - trailEraseAlpha(1 / 30)) ** 30;
|
||||
const remaining60 = (1 - trailEraseAlpha(1 / 60)) ** 60;
|
||||
assert.ok(Math.abs(remaining30 - remaining60) < 1e-12);
|
||||
assert.ok(Math.abs(1 - trailEraseAlpha(0.65) - 0.5) < 1e-12);
|
||||
assert.equal(windTrailColor(-10), windTrailColor(0));
|
||||
assert.equal(windTrailColor(100), windTrailColor(30));
|
||||
assert.notEqual(windTrailColor(0), windTrailColor(30));
|
||||
});
|
||||
|
||||
test('hot-loop sampling fills the supplied output without allocating a replacement', () => {
|
||||
const result = {};
|
||||
assert.equal(
|
||||
sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
0,
|
||||
0,
|
||||
result,
|
||||
),
|
||||
result,
|
||||
);
|
||||
assert.deepEqual(result, { u: 3, v: 4 });
|
||||
});
|
||||
|
||||
|
||||
test('temperature uses a fixed Celsius scale with visible everyday gradients', () => {
|
||||
const spec = WIND_FIELDS.temperature;
|
||||
assert.equal(spec.units, '°C');
|
||||
assert.deepEqual(
|
||||
spec.stops.map((_, index) => spec.min + index * (spec.max - spec.min) / (spec.stops.length - 1)),
|
||||
[-40, -30, -20, -10, 0, 10, 20, 30, 40, 50],
|
||||
);
|
||||
const colorAt = (temperature) => {
|
||||
const field = { ...snapshot, scalar: { ...snapshot.scalar, values: new Float32Array(8).fill(temperature) } };
|
||||
assert.equal(sampleScalar(field, 0, 0, 'temperature'), temperature, 'display mapping does not change measurements');
|
||||
const raster = createFieldRaster(field, 'temperature', 2, 2);
|
||||
assert.ok(raster.rgba[3] >= 225, 'temperature color dominates the basemap rather than washing out');
|
||||
return [...raster.rgba.slice(0, 3)];
|
||||
};
|
||||
let previous = colorAt(0);
|
||||
for (const temperature of [5, 10, 15, 20, 25, 30, 35]) {
|
||||
const color = colorAt(temperature);
|
||||
const distance = Math.hypot(...color.map((value, index) => value - previous[index]));
|
||||
assert.ok(distance >= 30, `five-degree difference near ${temperature}°C remains visually distinct`);
|
||||
previous = color;
|
||||
}
|
||||
assert.deepEqual(colorAt(-60), colorAt(-40), 'only color saturates below the fixed scale');
|
||||
assert.deepEqual(colorAt(60), colorAt(50), 'only color saturates above the fixed scale');
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import {
|
||||
bakeWindStreamlines,
|
||||
groupWindPaths,
|
||||
WIND_DISPLAY_HEIGHT_METERS,
|
||||
WIND_PATH_LIMIT,
|
||||
WIND_NARROW_PATH_LIMIT,
|
||||
} from './streamlines.js';
|
||||
|
||||
export const WIND_FADE_LOW_METERS = 15000;
|
||||
export const WIND_FADE_HIGH_METERS = 60000;
|
||||
|
||||
// Original material: a persistent fine curve plus a moving, tapered highlight.
|
||||
// The integer part of s identifies a path; its fractional part follows the wind.
|
||||
const FLOW_MATERIAL = `
|
||||
in float v_windFacing;
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float pathId = floor(materialInput.st.s);
|
||||
float along = fract(materialInput.st.s);
|
||||
float offset = fract((pathId + 1.0) * 0.61803398875);
|
||||
float behind = fract(phaseTime * 0.16 + offset - along);
|
||||
float tail = (1.0 - smoothstep(0.0, 0.32, behind));
|
||||
float tip = 1.0 - smoothstep(0.0, 0.045, behind);
|
||||
float ends = smoothstep(0.0, 0.055, along) * (1.0 - smoothstep(0.945, 1.0, along));
|
||||
float edge = 1.0 - smoothstep(0.26, 0.5, abs(materialInput.st.t - 0.5));
|
||||
float horizon = smoothstep(0.0, 0.065, v_windFacing);
|
||||
material.diffuse = mix(vec3(0.50, 0.79, 0.90), vec3(0.91, 0.99, 1.0), tip);
|
||||
material.alpha = (ghostAlpha + 0.64 * tail + 0.16 * tip) * ends * edge * horizon * heightFade;
|
||||
return material;
|
||||
}`;
|
||||
|
||||
/** Native Cesium geometry/material owner. No animation loop or DOM ownership. */
|
||||
export function createWindGpuRendering({ cesium: C, getViewer }) {
|
||||
const cells = [];
|
||||
let occluder = null;
|
||||
let collection = null;
|
||||
let material = null;
|
||||
let paused = false;
|
||||
let destroyed = false;
|
||||
const emptyDiagnostics = () => ({
|
||||
pathCount: 0,
|
||||
vertexCount: 0,
|
||||
buildMs: 0,
|
||||
error: null,
|
||||
cellCount: 0,
|
||||
visibleCells: 0,
|
||||
visibleVertexCount: 0,
|
||||
heightFade: 0,
|
||||
});
|
||||
let diagnostics = emptyDiagnostics();
|
||||
|
||||
function supported() {
|
||||
const scene = getViewer?.()?.scene;
|
||||
return (
|
||||
!destroyed &&
|
||||
Boolean(
|
||||
scene?.primitives &&
|
||||
C?.Primitive &&
|
||||
C?.GeometryInstance &&
|
||||
C?.GeometryInstanceAttribute &&
|
||||
C?.ComponentDatatype?.FLOAT !== undefined &&
|
||||
C?.PolylineGeometry &&
|
||||
C?.PolylineMaterialAppearance &&
|
||||
C?.Material &&
|
||||
C?.BoundingSphere?.fromPoints &&
|
||||
C?.Occluder &&
|
||||
C?.Ellipsoid?.WGS84 &&
|
||||
C?.Intersect &&
|
||||
C?.Cartesian3?.fromDegrees,
|
||||
) &&
|
||||
(C.SceneMode?.SCENE3D === undefined ||
|
||||
scene.mode === undefined ||
|
||||
scene.mode === C.SceneMode.SCENE3D)
|
||||
);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
for (const cell of cells) {
|
||||
collection?.remove(cell.primitive);
|
||||
if (!cell.primitive.isDestroyed?.()) cell.primitive.destroy?.();
|
||||
}
|
||||
cells.length = 0;
|
||||
occluder = null;
|
||||
collection = null;
|
||||
material?.destroy?.();
|
||||
material = null;
|
||||
diagnostics = emptyDiagnostics();
|
||||
}
|
||||
|
||||
function setField(field) {
|
||||
clear();
|
||||
if (!supported() || !field) return false;
|
||||
const started = globalThis.performance?.now?.() ?? Date.now();
|
||||
try {
|
||||
const width = getViewer().scene.canvas?.clientWidth;
|
||||
const budget =
|
||||
width > 0 && width < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
|
||||
const paths = bakeWindStreamlines(field, { count: budget });
|
||||
if (!paths.length) return false;
|
||||
let vertexCount = 0;
|
||||
material = new C.Material({
|
||||
fabric: {
|
||||
type: 'GevWindStreamline',
|
||||
uniforms: {
|
||||
phaseTime: 0,
|
||||
ghostAlpha: paused ? 0.34 : 0.25,
|
||||
heightFade: 0,
|
||||
},
|
||||
source: FLOW_MATERIAL,
|
||||
},
|
||||
translucent: () => true,
|
||||
});
|
||||
const base = new C.PolylineMaterialAppearance({ material });
|
||||
// Extend the public default shader rather than duplicate Cesium's polyline
|
||||
// expansion/precision code. Explicit facing also masks the far hemisphere
|
||||
// when a scene chooses not to preserve globe depth for translucent objects.
|
||||
const source = base.vertexShaderSource;
|
||||
if (
|
||||
!/void\s+main\s*\(\s*\)/.test(source) ||
|
||||
!/v_st\.s\s*=\s*st\.s\s*;/.test(source)
|
||||
)
|
||||
throw new Error('Wind polyline shader entry unavailable');
|
||||
const seededSource = source.replace(
|
||||
/v_st\.s\s*=\s*st\.s\s*;/,
|
||||
'v_st.s = st.s * 0.999 + czm_batchTable_windSeed(batchId);',
|
||||
);
|
||||
const vertexShaderSource =
|
||||
`out float v_windFacing;\n${seededSource}`.replace(
|
||||
/void\s+main\s*\(\s*\)\s*\{/,
|
||||
`void main() {\nvec3 windWorld = (czm_model * vec4(position3DHigh + position3DLow, 1.0)).xyz;\nv_windFacing = dot(normalize(windWorld), normalize(czm_viewerPositionWC - windWorld));\n`,
|
||||
);
|
||||
const scene = getViewer().scene;
|
||||
collection = scene.primitives;
|
||||
// EllipsoidalOccluder only tests points. Use Cesium's sphere occluder
|
||||
// with an inscribed Earth sphere, independent of scene.globe.show.
|
||||
occluder = new C.Occluder(
|
||||
new C.BoundingSphere(
|
||||
C.Cartesian3.ZERO,
|
||||
C.Ellipsoid.WGS84.minimumRadius,
|
||||
),
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
for (const group of groupWindPaths(paths)) {
|
||||
const cellPositions = [];
|
||||
let cellVertexCount = 0;
|
||||
const instances = group.paths.map((path) => {
|
||||
const positions = path.coordinates.map(([lon, lat]) =>
|
||||
C.Cartesian3.fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS),
|
||||
);
|
||||
// Pass the native description to Cesium's worker. Per-instance seed
|
||||
// attributes avoid touching the expanded vertex buffer on the UI thread.
|
||||
const geometry = new C.PolylineGeometry({
|
||||
positions,
|
||||
width: 2.4,
|
||||
arcType: C.ArcType?.NONE,
|
||||
vertexFormat: C.PolylineMaterialAppearance.VERTEX_FORMAT,
|
||||
});
|
||||
cellPositions.push(...positions);
|
||||
cellVertexCount += positions.length * 4 - 4;
|
||||
return new C.GeometryInstance({
|
||||
geometry,
|
||||
attributes: {
|
||||
windSeed: new C.GeometryInstanceAttribute({
|
||||
componentDatatype: C.ComponentDatatype.FLOAT,
|
||||
componentsPerAttribute: 1,
|
||||
value: [path.seed],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
const appearance = new C.PolylineMaterialAppearance({
|
||||
material,
|
||||
vertexShaderSource,
|
||||
translucent: true,
|
||||
renderState: { depthTest: { enabled: true }, depthMask: false },
|
||||
});
|
||||
const primitive = new C.Primitive({
|
||||
show: false,
|
||||
geometryInstances: instances,
|
||||
appearance,
|
||||
asynchronous: true,
|
||||
allowPicking: false,
|
||||
releaseGeometryInstances: true,
|
||||
compressVertices: false,
|
||||
});
|
||||
const cell = {
|
||||
primitive,
|
||||
sphere: C.BoundingSphere.fromPoints(cellPositions),
|
||||
vertexCount: cellVertexCount,
|
||||
};
|
||||
cells.push(cell);
|
||||
collection.add(primitive);
|
||||
vertexCount += cellVertexCount;
|
||||
}
|
||||
diagnostics = {
|
||||
...emptyDiagnostics(),
|
||||
cellCount: cells.length,
|
||||
pathCount: paths.length,
|
||||
vertexCount,
|
||||
buildMs: (globalThis.performance?.now?.() ?? Date.now()) - started,
|
||||
error: null,
|
||||
};
|
||||
return true;
|
||||
} catch (error) {
|
||||
clear();
|
||||
diagnostics.error =
|
||||
error instanceof Error ? error.message : 'Wind geometry unavailable';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Cesium reuses its frustum culling volume and the occluder's scratch vectors.
|
||||
// This loop creates no positions, spheres, appearances or per-cell temporaries.
|
||||
function updateVisibility(camera) {
|
||||
const height = camera.positionCartographic.height;
|
||||
const heightFade = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
1,
|
||||
(height - WIND_FADE_LOW_METERS) /
|
||||
(WIND_FADE_HIGH_METERS - WIND_FADE_LOW_METERS),
|
||||
),
|
||||
);
|
||||
diagnostics.heightFade = heightFade;
|
||||
if (material) material.uniforms.heightFade = heightFade;
|
||||
diagnostics.visibleCells = 0;
|
||||
diagnostics.visibleVertexCount = 0;
|
||||
let volume;
|
||||
if (heightFade > 0 && cells.length) {
|
||||
occluder.cameraPosition = camera.positionWC;
|
||||
volume = camera.frustum.computeCullingVolume(
|
||||
camera.positionWC,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
cell.primitive.show =
|
||||
heightFade > 0 &&
|
||||
occluder.isBoundingSphereVisible(cell.sphere) &&
|
||||
volume.computeVisibility(cell.sphere) !== C.Intersect.OUTSIDE;
|
||||
if (cell.primitive.show) {
|
||||
diagnostics.visibleCells++;
|
||||
diagnostics.visibleVertexCount += cell.vertexCount;
|
||||
}
|
||||
}
|
||||
return diagnostics.visibleCells > 0 && heightFade > 0;
|
||||
}
|
||||
|
||||
return {
|
||||
supported,
|
||||
setField,
|
||||
updateVisibility,
|
||||
tick(elapsedSeconds) {
|
||||
if (material && !paused && Number.isFinite(elapsedSeconds))
|
||||
material.uniforms.phaseTime = Math.max(0, elapsedSeconds) % 10000;
|
||||
},
|
||||
setOptions(options = {}) {
|
||||
if (typeof options.paused === 'boolean') paused = options.paused;
|
||||
if (material) material.uniforms.ghostAlpha = paused ? 0.34 : 0.25;
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
clear();
|
||||
destroyed = true;
|
||||
},
|
||||
getParticleCount: () => diagnostics.pathCount,
|
||||
getDiagnostics: () => ({
|
||||
...diagnostics,
|
||||
ready: cells.length > 0 && cells.every((cell) => cell.primitive.ready),
|
||||
mode: 'gpu-streamlines',
|
||||
displayHeightMeters: WIND_DISPLAY_HEIGHT_METERS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import {
|
||||
BoundingSphere,
|
||||
Cartesian3,
|
||||
Ellipsoid,
|
||||
Intersect,
|
||||
Occluder,
|
||||
PerspectiveFrustum,
|
||||
} from 'cesium';
|
||||
import { bakeWindStreamlines } from './streamlines.js';
|
||||
|
||||
function harness({ width = 1000 } = {}) {
|
||||
const members = new Set();
|
||||
let geometryBuilds = 0;
|
||||
const spheres = [];
|
||||
const visibility = {
|
||||
horizon: () => true,
|
||||
frustum: () => Intersect.INSIDE,
|
||||
horizonTests: 0,
|
||||
frustumTests: 0,
|
||||
};
|
||||
let occluderBuilds = 0;
|
||||
class Material {
|
||||
constructor(options) {
|
||||
this.uniforms = options.fabric.uniforms;
|
||||
this.source = options.fabric.source;
|
||||
}
|
||||
destroy() {
|
||||
assert.ok(!this.destroyed, 'owned resource destroyed once');
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
class Appearance {
|
||||
static VERTEX_FORMAT = {};
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.vertexShaderSource ??=
|
||||
'in vec3 position3DHigh; in vec3 position3DLow; void main() { gl_Position = vec4(0.0); v_st.s = st.s; }';
|
||||
}
|
||||
}
|
||||
class Polyline {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
static createGeometry(options) {
|
||||
geometryBuilds++;
|
||||
const st = new Float32Array(options.positions.length * 2);
|
||||
for (let i = 0; i < options.positions.length; i++)
|
||||
st[i * 2] = i / (options.positions.length - 1);
|
||||
return { attributes: { st: { values: st } } };
|
||||
}
|
||||
}
|
||||
class Primitive {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
this.ready = true;
|
||||
}
|
||||
destroy() {
|
||||
assert.ok(!this.destroyed, 'owned resource destroyed once');
|
||||
this.destroyed = true;
|
||||
}
|
||||
isDestroyed() {
|
||||
return Boolean(this.destroyed);
|
||||
}
|
||||
}
|
||||
const C = {
|
||||
ComponentDatatype: { FLOAT: 5126 },
|
||||
GeometryInstanceAttribute: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
Material,
|
||||
PolylineMaterialAppearance: Appearance,
|
||||
PolylineGeometry: Polyline,
|
||||
Primitive,
|
||||
GeometryInstance: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
Cartesian3,
|
||||
Ellipsoid,
|
||||
Intersect,
|
||||
BoundingSphere: class extends BoundingSphere {
|
||||
static fromPoints(points) {
|
||||
const sphere = BoundingSphere.fromPoints(points);
|
||||
spheres.push({ sphere, points });
|
||||
return sphere;
|
||||
}
|
||||
},
|
||||
Occluder: class {
|
||||
constructor(sphere, cameraPosition) {
|
||||
occluderBuilds++;
|
||||
this.cameraPosition = cameraPosition;
|
||||
}
|
||||
isBoundingSphereVisible(sphere) {
|
||||
visibility.horizonTests++;
|
||||
return visibility.horizon(sphere, this.cameraPosition);
|
||||
}
|
||||
},
|
||||
ArcType: { NONE: 0 },
|
||||
};
|
||||
const volume = {
|
||||
computeVisibility(sphere) {
|
||||
visibility.frustumTests++;
|
||||
return visibility.frustum(sphere);
|
||||
},
|
||||
};
|
||||
const scene = {
|
||||
globe: { show: false },
|
||||
camera: {
|
||||
positionWC: Cartesian3.fromDegrees(0, 0, 1e7),
|
||||
positionCartographic: { height: 1e7 },
|
||||
directionWC: new Cartesian3(-1, 0, 0),
|
||||
upWC: new Cartesian3(0, 0, 1),
|
||||
frustum: { computeCullingVolume: () => volume },
|
||||
},
|
||||
canvas: { clientWidth: width },
|
||||
primitives: {
|
||||
add(item) {
|
||||
members.add(item);
|
||||
},
|
||||
remove(item) {
|
||||
const removed = members.delete(item);
|
||||
if (removed) item.destroy();
|
||||
return removed;
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
C,
|
||||
scene,
|
||||
members,
|
||||
builds: () => geometryBuilds,
|
||||
spheres,
|
||||
visibility,
|
||||
occluderBuilds: () => occluderBuilds,
|
||||
owner: createWindGpuRendering({ cesium: C, getViewer: () => ({ scene }) }),
|
||||
};
|
||||
}
|
||||
const field = () => ({
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
u: new Float32Array(12).fill(15),
|
||||
v: new Float32Array(12),
|
||||
});
|
||||
|
||||
test('GPU owner builds regional native batches; ticks update uniforms without rebuilding geometry', () => {
|
||||
const h = harness();
|
||||
assert.equal(h.owner.setField(field()), true);
|
||||
assert.equal(h.members.size, 72);
|
||||
const batch = [...h.members][0];
|
||||
assert.equal(batch.allowPicking, false);
|
||||
assert.equal(batch.asynchronous, true);
|
||||
assert.ok(batch.appearance.vertexShaderSource.includes('v_windFacing = dot'));
|
||||
assert.ok(
|
||||
batch.appearance.material.source.includes(
|
||||
'smoothstep(0.0, 0.065, v_windFacing)',
|
||||
),
|
||||
);
|
||||
const builds = h.builds();
|
||||
assert.equal(builds, 0, 'expanded geometry is deferred to Cesium workers');
|
||||
assert.ok(
|
||||
batch.appearance.vertexShaderSource.includes(
|
||||
'czm_batchTable_windSeed(batchId)',
|
||||
),
|
||||
);
|
||||
h.owner.tick(3);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
|
||||
h.owner.setOptions({ paused: true });
|
||||
h.owner.tick(8);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
|
||||
h.owner.setOptions({ paused: false });
|
||||
h.owner.tick(9);
|
||||
assert.equal(batch.appearance.material.uniforms.phaseTime, 9);
|
||||
assert.equal(h.builds(), builds);
|
||||
const diagnostics = h.owner.getDiagnostics();
|
||||
assert.ok(diagnostics.pathCount <= 7200);
|
||||
assert.ok(
|
||||
diagnostics.pathCount > 7000,
|
||||
'desktop retains the requested doubled global density',
|
||||
);
|
||||
assert.ok(diagnostics.vertexCount > 0);
|
||||
assert.ok(
|
||||
diagnostics.vertexCount <= 7200 * 128,
|
||||
'expanded geometry remains bounded',
|
||||
);
|
||||
assert.equal(diagnostics.ready, true);
|
||||
assert.equal(diagnostics.displayHeightMeters, 12000);
|
||||
assert.equal(diagnostics.cellCount, 72);
|
||||
assert.equal(h.spheres.length, 72);
|
||||
assert.equal(new Set([...h.members].map((p) => p.appearance)).size, 72);
|
||||
for (const cell of h.members) {
|
||||
assert.equal(cell.appearance.material, batch.appearance.material);
|
||||
assert.equal(cell.appearance.material.uniforms.phaseTime, 9);
|
||||
assert.equal(cell.appearance.material.uniforms.ghostAlpha, 0.25);
|
||||
assert.equal(cell.compressVertices, false);
|
||||
assert.equal(cell.releaseGeometryInstances, true);
|
||||
}
|
||||
batch.ready = false;
|
||||
assert.equal(
|
||||
h.owner.getDiagnostics().ready,
|
||||
false,
|
||||
'every cell must be ready',
|
||||
);
|
||||
batch.ready = true;
|
||||
const instances = [...h.members].flatMap((p) => p.geometryInstances);
|
||||
const baked = bakeWindStreamlines(field());
|
||||
assert.equal(instances.length, baked.length);
|
||||
const bySeed = new Map(
|
||||
instances.map((i) => [i.attributes.windSeed.value[0], i]),
|
||||
);
|
||||
for (const path of baked) {
|
||||
assert.deepEqual(
|
||||
bySeed.get(path.seed).geometry.positions,
|
||||
path.coordinates.map(([lon, lat]) =>
|
||||
Cartesian3.fromDegrees(lon, lat, 12000),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const { sphere, points } of h.spheres) {
|
||||
assert.deepEqual(sphere, BoundingSphere.fromPoints(points));
|
||||
for (const point of points)
|
||||
assert.ok(
|
||||
Cartesian3.distance(point, sphere.center) <= sphere.radius + 1e-6,
|
||||
);
|
||||
}
|
||||
for (const instance of instances) {
|
||||
assert.ok(instance.geometry instanceof h.C.PolylineGeometry);
|
||||
assert.equal(instance.geometry.width, 2.4);
|
||||
assert.equal(instance.attributes.windSeed.componentsPerAttribute, 1);
|
||||
assert.ok(Number.isInteger(instance.attributes.windSeed.value[0]));
|
||||
}
|
||||
});
|
||||
|
||||
test('replacement, clear and destroy dispose every owned cell and the shared material', () => {
|
||||
const h = harness();
|
||||
const assertDisposed = (batches) => {
|
||||
assert.equal(batches.length, 72);
|
||||
for (const batch of batches) {
|
||||
assert.equal(batch.destroyed, true);
|
||||
assert.equal(batch.appearance.material.destroyed, true);
|
||||
}
|
||||
};
|
||||
h.owner.setField(field());
|
||||
const first = [...h.members];
|
||||
h.owner.setField(field());
|
||||
assertDisposed(first);
|
||||
const second = [...h.members];
|
||||
h.owner.clear();
|
||||
assertDisposed(second);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(h.owner.getParticleCount(), 0);
|
||||
assert.equal(h.owner.getDiagnostics().ready, false);
|
||||
h.owner.setField(field());
|
||||
const third = [...h.members];
|
||||
h.owner.destroy();
|
||||
assertDisposed(third);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(h.owner.supported(), false);
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
});
|
||||
|
||||
test('unsupported or failed geometry cleanly requests the existing fallback', () => {
|
||||
const missing = createWindGpuRendering({
|
||||
cesium: {},
|
||||
getViewer: () => ({ scene: {} }),
|
||||
});
|
||||
assert.equal(missing.setField(field()), false);
|
||||
const h = harness();
|
||||
h.C.PolylineGeometry = class {
|
||||
constructor() {
|
||||
throw new Error('test build failure');
|
||||
}
|
||||
};
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.match(h.owner.getDiagnostics().error, /test build failure/);
|
||||
});
|
||||
|
||||
test('narrow canvases use a lower bounded path budget', () => {
|
||||
const h = harness({ width: 390 });
|
||||
assert.equal(h.owner.setField(field()), true);
|
||||
assert.ok(h.owner.getParticleCount() <= 1200);
|
||||
assert.ok(h.owner.getParticleCount() > 1000);
|
||||
});
|
||||
|
||||
test('cell visibility combines horizon, frustum and height with shared fade uniforms', () => {
|
||||
const h = harness();
|
||||
h.owner.setField(field());
|
||||
const batches = [...h.members];
|
||||
const camera = h.scene.camera;
|
||||
h.visibility.horizon = (sphere) =>
|
||||
h.spheres.findIndex((s) => s.sphere === sphere) % 2 === 0;
|
||||
h.visibility.frustum = (sphere) =>
|
||||
h.spheres.findIndex((s) => s.sphere === sphere) % 3 === 0
|
||||
? Intersect.INTERSECTING
|
||||
: Intersect.OUTSIDE;
|
||||
assert.equal(h.owner.updateVisibility(camera), true);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 12);
|
||||
let vertices = 0;
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
assert.equal(batches[i].show, i % 6 === 0);
|
||||
if (batches[i].show)
|
||||
vertices += batches[i].geometryInstances.reduce(
|
||||
(sum, p) => sum + p.geometry.positions.length * 4 - 4,
|
||||
0,
|
||||
);
|
||||
}
|
||||
assert.equal(h.owner.getDiagnostics().visibleVertexCount, vertices);
|
||||
assert.equal(h.visibility.horizonTests, 72);
|
||||
assert.equal(h.visibility.frustumTests, 36);
|
||||
for (const [height, fade] of [
|
||||
[60000, 1],
|
||||
[37500, 0.5],
|
||||
[15000, 0],
|
||||
[1200, 0],
|
||||
]) {
|
||||
camera.positionCartographic.height = height;
|
||||
assert.equal(h.owner.updateVisibility(camera), fade > 0);
|
||||
assert.equal(h.owner.getDiagnostics().heightFade, fade);
|
||||
for (const batch of batches) {
|
||||
assert.equal(batch.appearance.material.uniforms.heightFade, fade);
|
||||
assert.match(batch.appearance.material.source, /horizon \* heightFade/);
|
||||
if (!fade) assert.equal(batch.show, false);
|
||||
}
|
||||
}
|
||||
assert.equal(h.owner.getDiagnostics().visibleVertexCount, 0);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
|
||||
assert.equal(
|
||||
h.visibility.horizonTests,
|
||||
216,
|
||||
'zero fade skips all sphere tests',
|
||||
);
|
||||
assert.equal(h.visibility.frustumTests, 108);
|
||||
assert.equal(
|
||||
h.occluderBuilds(),
|
||||
1,
|
||||
'occluder is reused across camera updates',
|
||||
);
|
||||
});
|
||||
|
||||
test('real Cesium sphere culling hides far hemisphere with globe hidden and rejects a sky-facing view', () => {
|
||||
const h = harness();
|
||||
h.C.Occluder = Occluder;
|
||||
h.owner.setField(field());
|
||||
const camera = h.scene.camera;
|
||||
camera.frustum = new PerspectiveFrustum({
|
||||
fov: Math.PI / 3,
|
||||
aspectRatio: 1.5,
|
||||
near: 1,
|
||||
far: 5e7,
|
||||
});
|
||||
assert.equal(h.owner.updateVisibility(camera), true);
|
||||
const facingEarth = h.owner.getDiagnostics();
|
||||
assert.ok(facingEarth.visibleCells > 0 && facingEarth.visibleCells < 72);
|
||||
for (let i = 0; i < h.spheres.length; i++) {
|
||||
const sphere = h.spheres[i].sphere;
|
||||
if (sphere.center.x < -5e6) assert.equal([...h.members][i].show, false);
|
||||
}
|
||||
camera.directionWC.x = 1;
|
||||
assert.equal(h.owner.updateVisibility(camera), false);
|
||||
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
|
||||
camera.positionWC = Cartesian3.fromDegrees(180, 0, 1e7);
|
||||
assert.equal(
|
||||
h.owner.updateVisibility(camera),
|
||||
true,
|
||||
'camera position updates the owned horizon test',
|
||||
);
|
||||
h.owner.destroy();
|
||||
});
|
||||
|
||||
test('a failed later cell build releases already installed cells and shared material', () => {
|
||||
const h = harness();
|
||||
const NativePrimitive = h.C.Primitive;
|
||||
const built = [];
|
||||
h.C.Primitive = class extends NativePrimitive {
|
||||
constructor(options) {
|
||||
if (built.length === 2) throw new Error('later cell failed');
|
||||
super(options);
|
||||
built.push(this);
|
||||
}
|
||||
};
|
||||
assert.equal(h.owner.setField(field()), false);
|
||||
assert.equal(h.members.size, 0);
|
||||
assert.equal(built.length, 2);
|
||||
for (const batch of built) {
|
||||
assert.equal(batch.destroyed, true);
|
||||
assert.equal(batch.appearance.material.destroyed, true);
|
||||
}
|
||||
assert.match(h.owner.getDiagnostics().error, /later cell failed/);
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWindRendering } from './rendering.js';
|
||||
import { WIND_FIELDS } from './fields.js';
|
||||
import {
|
||||
formatWindReading,
|
||||
windReadingResult,
|
||||
windUnitChips,
|
||||
} from './presentation.js';
|
||||
import {
|
||||
inspectWindAtCenter,
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
|
||||
/** Format a forecast timestamp explicitly in UTC. */
|
||||
export function formatWindValidTime(value) {
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms)
|
||||
? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Forecast issue time is source age; valid time is reported separately. */
|
||||
export function windStats(manifest) {
|
||||
const run = Date.parse(manifest?.cycle?.runIso);
|
||||
return {
|
||||
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
|
||||
lastUpdate: Number.isFinite(run) ? run : null,
|
||||
error:
|
||||
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
|
||||
};
|
||||
}
|
||||
|
||||
/** Construct one wind layer with an explicit source and owned animation. */
|
||||
export function createWindLayer({
|
||||
feed,
|
||||
clock,
|
||||
cesium = Cesium,
|
||||
container,
|
||||
createRendering = createWindRendering,
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Wind requires a snapshot source');
|
||||
let viewer = null;
|
||||
let request = null;
|
||||
let enabled = false;
|
||||
let rendering = null;
|
||||
let imageryHost = null;
|
||||
let manifest = null;
|
||||
let error = null;
|
||||
let loading = false;
|
||||
let model = 'gfs';
|
||||
let overlay = 'none';
|
||||
let paused = false;
|
||||
let units = 'km/h';
|
||||
let reading = null;
|
||||
let inspectionMarker = null;
|
||||
let sampledPosition = null;
|
||||
const hideInspection = () => {
|
||||
reading = null;
|
||||
sampledPosition = null;
|
||||
inspectionMarker?.clear();
|
||||
};
|
||||
const sample = (position) => {
|
||||
reading = inspectWindAtCenter(manifest, viewer, cesium, {
|
||||
position,
|
||||
units,
|
||||
overlay,
|
||||
model: model === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : 'Model forecast'),
|
||||
});
|
||||
sampledPosition = reading.position || position || null;
|
||||
inspectionMarker?.show(sampledPosition);
|
||||
};
|
||||
const requestedScalar = () =>
|
||||
['temperature', 'pressure'].includes(overlay) ? overlay : 'none';
|
||||
let generation = 0;
|
||||
let rowControlsListener = null;
|
||||
const notify = () => rowControlsListener?.();
|
||||
const unsubscribeClock = clock?.subscribe(notify);
|
||||
const layer = {
|
||||
id: 'wind',
|
||||
name: 'Wind',
|
||||
icon: '🌬',
|
||||
source: 'GFS / ECMWF IFS · FORECAST',
|
||||
updateInterval: 3600_000,
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
rendering = createRendering({
|
||||
cesium,
|
||||
container: container ?? nextViewer.container,
|
||||
getViewer: () => viewer,
|
||||
getHost: () =>
|
||||
imageryHost?.() ?? {
|
||||
collection: viewer.imageryLayers ?? viewer.scene?.imageryLayers,
|
||||
kind: 'globe',
|
||||
},
|
||||
onStatusChange: notify,
|
||||
});
|
||||
rendering.attach();
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
const target = container ?? nextViewer.container;
|
||||
inspectionMarker = createWindInspectionMarker({
|
||||
container: target,
|
||||
viewer,
|
||||
cesium,
|
||||
});
|
||||
},
|
||||
attachShellServices(services) {
|
||||
imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
rendering?.rehome?.();
|
||||
},
|
||||
enable() {
|
||||
enabled = true;
|
||||
rendering?.start();
|
||||
},
|
||||
disable() {
|
||||
enabled = false;
|
||||
generation += 1;
|
||||
request?.abort();
|
||||
request = null;
|
||||
loading = false;
|
||||
// The renderer releases its field on disable. Retaining a manifest here
|
||||
// would falsely satisfy an appearance-only reuse after the next enable.
|
||||
manifest = null;
|
||||
error = null;
|
||||
hideInspection();
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(nextViewer, { signal } = {}) {
|
||||
if (!enabled) return false;
|
||||
rendering?.rehome?.();
|
||||
request?.abort();
|
||||
const controller = new AbortController();
|
||||
if (signal?.aborted) controller.abort(signal.reason);
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
request = controller;
|
||||
generation += 1;
|
||||
loading = true;
|
||||
notify();
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const snapshot = await feed.getSnapshot({
|
||||
signal: controller.signal,
|
||||
model,
|
||||
overlay: requestedScalar(),
|
||||
});
|
||||
if (!enabled || controller.signal.aborted || request !== controller)
|
||||
return false;
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
if (sampledPosition) sample(sampledPosition);
|
||||
if (snapshot.unavailable) {
|
||||
rendering.stop();
|
||||
rendering.clear();
|
||||
} else {
|
||||
rendering.setOptions?.({ overlay, paused });
|
||||
rendering.setField(snapshot);
|
||||
rendering.start();
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
error = cause?.message || 'Wind source unavailable';
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
if (request === controller) {
|
||||
request = null;
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
},
|
||||
setParams(params = {}) {
|
||||
const previousScalar = requestedScalar();
|
||||
const modelChanged =
|
||||
['gfs', 'ifs'].includes(params.model) && params.model !== model;
|
||||
const overlayChanged =
|
||||
['none', 'speed', 'temperature', 'pressure'].includes(params.overlay) &&
|
||||
params.overlay !== overlay;
|
||||
if (typeof params.paused === 'boolean') paused = params.paused;
|
||||
const unitsChanged =
|
||||
Object.hasOwn(WIND_UNITS, params.units) && params.units !== units;
|
||||
if (unitsChanged) units = params.units;
|
||||
if (modelChanged) model = params.model;
|
||||
if (overlayChanged) overlay = params.overlay;
|
||||
rendering?.setOptions?.({ overlay, paused });
|
||||
if (unitsChanged) reading = formatWindReading(reading, units);
|
||||
if (params.inspect === false) hideInspection();
|
||||
if (modelChanged) {
|
||||
manifest = null;
|
||||
error = null;
|
||||
rendering?.stop();
|
||||
rendering?.clear();
|
||||
}
|
||||
const needsField =
|
||||
modelChanged ||
|
||||
(overlayChanged &&
|
||||
(!manifest?.u ||
|
||||
manifest.unavailable ||
|
||||
(requestedScalar() !== 'none' &&
|
||||
manifest?.scalar?.kind !== overlay)));
|
||||
const changesRequest =
|
||||
modelChanged ||
|
||||
(overlayChanged && previousScalar !== requestedScalar());
|
||||
// None and Speed use the identical U/V request. Keep an active refresh
|
||||
// alive while changing its presentation, including the first load.
|
||||
if (changesRequest || (overlayChanged && needsField && !request)) {
|
||||
request?.abort();
|
||||
request = null;
|
||||
const current = ++generation;
|
||||
loading = enabled && needsField;
|
||||
if (enabled && needsField)
|
||||
queueMicrotask(() => {
|
||||
if (enabled && generation === current) void layer.update(viewer);
|
||||
});
|
||||
else if (enabled && manifest && !manifest.unavailable)
|
||||
rendering?.start();
|
||||
}
|
||||
if (overlayChanged && !needsField && sampledPosition)
|
||||
sample(sampledPosition);
|
||||
if (params.inspect === true && enabled) {
|
||||
sample();
|
||||
}
|
||||
notify();
|
||||
},
|
||||
getParams() {
|
||||
return { model, overlay, paused, units };
|
||||
},
|
||||
getRowControls() {
|
||||
const historyStatus =
|
||||
clock?.getState().mode === 'history'
|
||||
? 'Forecast · does not follow history'
|
||||
: null;
|
||||
const valid = formatWindValidTime(manifest?.cycle?.validIso);
|
||||
const run = formatWindValidTime(manifest?.cycle?.runIso);
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
const motionPaused = paused || diagnostic?.reducedMotion;
|
||||
const preparing =
|
||||
enabled &&
|
||||
diagnostic?.renderMode === 'gpu-streamlines' &&
|
||||
diagnostic.gpu?.pathCount > 0 &&
|
||||
!diagnostic.gpu?.ready;
|
||||
const scalarMissing =
|
||||
!loading &&
|
||||
requestedScalar() !== 'none' &&
|
||||
manifest &&
|
||||
(!manifest.scalar || manifest.scalar.kind !== overlay);
|
||||
const imageryError =
|
||||
enabled && !loading ? diagnostic?.imageryError : null;
|
||||
const label = {
|
||||
none: 'Wind motion',
|
||||
speed: 'Wind speed',
|
||||
pressure: 'Sea-level pressure',
|
||||
temperature: 'Air temperature · 2 m',
|
||||
}[overlay];
|
||||
const kind = ['temperature', 'pressure'].includes(overlay)
|
||||
? overlay
|
||||
: 'speed';
|
||||
const spec = WIND_FIELDS[kind];
|
||||
const legendUnit = kind === 'speed' ? units : spec.units;
|
||||
const legend = spec.stops.map((color, index) => {
|
||||
const value =
|
||||
spec.min + ((spec.max - spec.min) * index) / (spec.stops.length - 1);
|
||||
const display = Math.round(
|
||||
kind === 'speed' ? value * WIND_UNITS[units] : value,
|
||||
);
|
||||
return {
|
||||
color,
|
||||
label: `${display}${index === spec.stops.length - 1 ? '+' : ''}`,
|
||||
};
|
||||
});
|
||||
const speedLegendVisible =
|
||||
!imageryError &&
|
||||
(overlay === 'speed' ||
|
||||
(overlay === 'none' && diagnostic?.renderMode === 'canvas-fallback'));
|
||||
const controls = {
|
||||
readout: true,
|
||||
summary: {
|
||||
label,
|
||||
coverage: 'Global · 1° grid',
|
||||
validTime: manifest?.cycle?.validIso,
|
||||
issuedTime: manifest?.cycle?.runIso,
|
||||
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
|
||||
status: loading
|
||||
? 'Loading forecast'
|
||||
: preparing
|
||||
? 'Preparing flow'
|
||||
: error ||
|
||||
manifest?.reason ||
|
||||
imageryError ||
|
||||
(scalarMissing ? 'Selected field unavailable' : null) ||
|
||||
(manifest?.stale ? 'Cached forecast · stale' : null),
|
||||
units: legendUnit,
|
||||
},
|
||||
chips: [
|
||||
...['gfs', 'ifs'].map((value) => ({
|
||||
id: `model-${value}`,
|
||||
label: value === 'ifs' ? 'ECMWF' : 'GFS',
|
||||
active: model === value,
|
||||
params: { model: value },
|
||||
title:
|
||||
value === 'ifs'
|
||||
? 'ECMWF IFS surface forecast'
|
||||
: 'NOAA GFS surface forecast',
|
||||
})),
|
||||
{
|
||||
id: 'motion',
|
||||
label: diagnostic?.reducedMotion
|
||||
? 'Reduced motion'
|
||||
: paused
|
||||
? 'Resume'
|
||||
: 'Pause',
|
||||
active: motionPaused,
|
||||
disabled: Boolean(diagnostic?.reducedMotion),
|
||||
params: { paused: !paused },
|
||||
title:
|
||||
'Pause visual flow; forecast time does not advance with animation',
|
||||
},
|
||||
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
|
||||
id: `overlay-${value}`,
|
||||
label: {
|
||||
none: 'None',
|
||||
speed: 'Speed',
|
||||
pressure: 'Pressure',
|
||||
temperature: 'Temperature',
|
||||
}[value],
|
||||
active: overlay === value,
|
||||
params: { overlay: value },
|
||||
title: {
|
||||
none: 'Wind trails with no field shading',
|
||||
speed: 'How hard the surface wind is blowing',
|
||||
pressure: 'Sea-level air pressure: broad highs and lows',
|
||||
temperature: 'Air temperature two meters above the surface',
|
||||
}[value],
|
||||
})),
|
||||
...(speedLegendVisible ||
|
||||
diagnostic?.renderMode === 'canvas-fallback' ||
|
||||
Number.isFinite(reading?.speed)
|
||||
? Object.keys(WIND_UNITS)
|
||||
: []
|
||||
).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
})),
|
||||
{
|
||||
id: 'read-wind',
|
||||
label: 'Read wind at map center',
|
||||
params: { inspect: true },
|
||||
disabled: loading || !manifest?.u || manifest?.unavailable,
|
||||
title:
|
||||
'Read the forecast at the center of the map without changing selection',
|
||||
},
|
||||
],
|
||||
legend:
|
||||
scalarMissing ||
|
||||
imageryError ||
|
||||
(overlay === 'none' && !speedLegendVisible)
|
||||
? []
|
||||
: legend,
|
||||
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
|
||||
infoTitle:
|
||||
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
|
||||
};
|
||||
controls.summary.reading = reading;
|
||||
controls.summary.result = reading ? windReadingResult(reading) : null;
|
||||
const setting = (id, label, prefix) => ({
|
||||
id,
|
||||
label,
|
||||
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
|
||||
});
|
||||
controls.summary.settings = [
|
||||
setting('model', 'MODEL', 'model-'),
|
||||
setting('field', 'FIELD', 'overlay-'),
|
||||
...(controls.chips.some(({ id }) => id.startsWith('units-'))
|
||||
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
|
||||
: []),
|
||||
setting('motion', 'MOTION', 'motion'),
|
||||
];
|
||||
controls.summary.actions = controls.chips.filter(
|
||||
({ id }) => id === 'read-wind',
|
||||
);
|
||||
return controls;
|
||||
},
|
||||
|
||||
setRowControlsListener(listener) {
|
||||
rowControlsListener = typeof listener === 'function' ? listener : null;
|
||||
},
|
||||
destroy() {
|
||||
layer.disable();
|
||||
unsubscribeClock?.();
|
||||
rendering?.destroy();
|
||||
rendering = null;
|
||||
imageryHost = null;
|
||||
viewer = null;
|
||||
rowControlsListener = null;
|
||||
inspectionMarker?.destroy();
|
||||
inspectionMarker = null;
|
||||
},
|
||||
getStats() {
|
||||
const diagnostic = rendering?.getDiagnostics?.();
|
||||
const preparing =
|
||||
enabled &&
|
||||
diagnostic?.renderMode === 'gpu-streamlines' &&
|
||||
diagnostic.gpu?.pathCount > 0 &&
|
||||
!diagnostic.gpu?.ready;
|
||||
const imageryError =
|
||||
enabled && !loading
|
||||
? rendering?.getDiagnostics?.()?.imageryError
|
||||
: null;
|
||||
return {
|
||||
...windStats(manifest),
|
||||
countLabel: 'Forecast',
|
||||
loading: loading || Boolean(preparing),
|
||||
model: model.toUpperCase(),
|
||||
overlay,
|
||||
paused,
|
||||
stale: Boolean(manifest?.stale),
|
||||
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
|
||||
validTime:
|
||||
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
|
||||
error:
|
||||
error ||
|
||||
windStats(manifest).error ||
|
||||
(requestedScalar() !== 'none' ? manifest?.scalarError : null) ||
|
||||
imageryError,
|
||||
};
|
||||
},
|
||||
getDiagnostics() {
|
||||
return rendering?.getDiagnostics?.() || {};
|
||||
},
|
||||
getParticleCount() {
|
||||
return rendering?.getParticleCount() || 0;
|
||||
},
|
||||
};
|
||||
return layer;
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
|
||||
|
||||
const snapshot = (model) => ({
|
||||
model,
|
||||
grid: { nx: 2, ny: 2 },
|
||||
cycle: { runIso: '2026-09-14T12:00:00Z', validIso: '2026-09-14T18:00:00Z' },
|
||||
});
|
||||
function harness(feed, diagnostics = {}) {
|
||||
const calls = [];
|
||||
const rendering = Object.fromEntries(
|
||||
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
|
||||
name,
|
||||
(...args) => calls.push([name, ...args]),
|
||||
]),
|
||||
);
|
||||
rendering.getDiagnostics = () => diagnostics;
|
||||
const layer = createWindLayer({ feed, createRendering: () => rendering });
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
return { layer, calls };
|
||||
}
|
||||
test('forecast valid time and source age stay distinct', () => {
|
||||
assert.equal(formatWindValidTime('invalid'), null);
|
||||
assert.equal(
|
||||
formatWindValidTime('2026-01-05T06:30:00Z'),
|
||||
'2026-01-05 06:30 UTC',
|
||||
);
|
||||
assert.equal(
|
||||
windStats(snapshot('gfs')).lastUpdate,
|
||||
Date.parse('2026-09-14T12:00:00Z'),
|
||||
);
|
||||
});
|
||||
test('switching models clears old data and ignores an abort-insensitive late source', async () => {
|
||||
const pending = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => pending.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await Promise.resolve();
|
||||
assert.equal(pending.length, 2);
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(layer.getStats().model, 'IFS');
|
||||
pending[0].resolve(snapshot('gfs'));
|
||||
await first;
|
||||
assert.equal(calls.filter(([name]) => name === 'setField').length, 0);
|
||||
pending[1].resolve(snapshot('ifs'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(calls.find(([name]) => name === 'setField')[1].model, 'ifs');
|
||||
assert.match(
|
||||
layer.getRowControls().info,
|
||||
/IFS forecast[\s\S]*Valid: 2026-09-14 18:00 UTC[\s\S]*Issued: 2026-09-14 12:00 UTC/,
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
test('external and owned cancellation both cancel source work; queued switches stop on disable', async () => {
|
||||
let signal;
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) => {
|
||||
signal = args.signal;
|
||||
return new Promise((resolve) =>
|
||||
signal.addEventListener('abort', () => resolve(snapshot('gfs')), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const external = new AbortController();
|
||||
const update = layer.update(null, { signal: external.signal });
|
||||
layer.disable();
|
||||
assert.equal(signal.aborted, true);
|
||||
assert.equal(external.signal.aborted, false);
|
||||
assert.equal(await update, false);
|
||||
layer.enable();
|
||||
const other = layer.update(null, { signal: external.signal });
|
||||
external.abort();
|
||||
assert.equal(signal.aborted, true);
|
||||
await other;
|
||||
layer.setParams({ model: 'ifs' });
|
||||
layer.disable();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
const complete = (model, extra = {}) => ({
|
||||
...snapshot(model),
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: new Float32Array(4).fill(4),
|
||||
v: new Float32Array(4).fill(3),
|
||||
...extra,
|
||||
});
|
||||
test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => {
|
||||
const requests = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: async (args) => {
|
||||
requests.push(args);
|
||||
return complete(
|
||||
args.model,
|
||||
args.overlay === 'pressure'
|
||||
? {
|
||||
scalar: {
|
||||
kind: 'pressure',
|
||||
units: 'hPa',
|
||||
values: new Float32Array(4).fill(1013),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
);
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
layer.setParams({ overlay: 'speed', units: 'mph', paused: true });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 1, 'speed/units/pause do not download weather');
|
||||
assert.deepEqual(layer.getParams(), {
|
||||
model: 'gfs',
|
||||
overlay: 'speed',
|
||||
units: 'mph',
|
||||
paused: true,
|
||||
});
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].overlay, 'pressure');
|
||||
assert.equal(layer.getStats().overlay, 'pressure');
|
||||
assert.ok(layer.getRowControls().info.includes('(hPa)'));
|
||||
assert.equal(layer.getRowControls().legend.at(-1).label, '1050+');
|
||||
layer.destroy();
|
||||
});
|
||||
test('local appearance changes preserve an in-flight first load', async () => {
|
||||
const requests = [];
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => requests.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].signal.aborted, false);
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
requests[0].resolve(complete('gfs'));
|
||||
await first;
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
test('re-enable and an appearance change cannot reuse a released renderer field', async () => {
|
||||
const requests = [];
|
||||
const { layer, calls } = harness({
|
||||
getSnapshot: (args) =>
|
||||
new Promise((resolve) => requests.push({ ...args, resolve })),
|
||||
});
|
||||
const first = layer.update();
|
||||
requests[0].resolve(complete('gfs'));
|
||||
await first;
|
||||
layer.disable();
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
layer.enable();
|
||||
const second = layer.update();
|
||||
layer.setParams({ overlay: 'none' });
|
||||
await Promise.resolve();
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].signal.aborted, false);
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
requests[1].resolve(complete('gfs'));
|
||||
await second;
|
||||
assert.equal(
|
||||
calls.filter(([name]) => name === 'setField').length,
|
||||
2,
|
||||
'new field must reach the cleared renderer',
|
||||
);
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.equal(layer.getStats().loading, false);
|
||||
layer.destroy();
|
||||
});
|
||||
test('missing optional scalar retains valid wind and labels the field unavailable', async () => {
|
||||
const { layer } = harness({
|
||||
getSnapshot: async () =>
|
||||
complete('gfs', {
|
||||
overlay: 'pressure',
|
||||
scalarError: 'Mean sea level pressure field unavailable',
|
||||
}),
|
||||
});
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().count, 4);
|
||||
assert.match(layer.getStats().error, /pressure field unavailable/);
|
||||
assert.match(layer.getRowControls().info, /Selected field unavailable/);
|
||||
assert.deepEqual(layer.getRowControls().legend, []);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('a pending companion does not claim that it is already unavailable', async () => {
|
||||
let resolve;
|
||||
const { layer } = harness({
|
||||
getSnapshot: (args) =>
|
||||
args.overlay === 'none'
|
||||
? Promise.resolve(complete('gfs'))
|
||||
: new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
});
|
||||
await layer.update();
|
||||
layer.setParams({ overlay: 'pressure' });
|
||||
await Promise.resolve();
|
||||
assert.equal(layer.getStats().loading, true);
|
||||
assert.doesNotMatch(
|
||||
layer.getRowControls().info,
|
||||
/Selected field unavailable/,
|
||||
);
|
||||
resolve(
|
||||
complete('gfs', {
|
||||
scalarError: 'Mean sea level pressure field unavailable',
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.match(layer.getRowControls().info, /Selected field unavailable/);
|
||||
layer.destroy();
|
||||
});
|
||||
test('an imagery failure appears in status and cannot retain a misleading field legend', async () => {
|
||||
const diagnostics = { imageryError: null };
|
||||
const { layer } = harness(
|
||||
{ getSnapshot: async () => complete('gfs') },
|
||||
diagnostics,
|
||||
);
|
||||
await layer.update();
|
||||
diagnostics.imageryError = 'Globe field image unavailable';
|
||||
assert.equal(layer.getStats().error, 'Globe field image unavailable');
|
||||
assert.match(layer.getRowControls().info, /Globe field image unavailable/);
|
||||
assert.deepEqual(layer.getRowControls().legend, []);
|
||||
layer.destroy();
|
||||
});
|
||||
test('renderer readiness pushes fresh loading stats and controls to the displayed row', async () => {
|
||||
let ready = false;
|
||||
let statusChanged;
|
||||
const rendering = {
|
||||
attach() {},
|
||||
start() {},
|
||||
stop() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
setField() {},
|
||||
getDiagnostics: () => ({
|
||||
renderMode: 'gpu-streamlines',
|
||||
gpu: { pathCount: 10, ready },
|
||||
}),
|
||||
};
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async () => snapshot('gfs') },
|
||||
createRendering(options) {
|
||||
statusChanged = options.onStatusChange;
|
||||
return rendering;
|
||||
},
|
||||
});
|
||||
layer.init({ container: {} });
|
||||
layer.enable();
|
||||
let displayed;
|
||||
layer.setRowControlsListener(() => {
|
||||
displayed = {
|
||||
loading: layer.getStats().loading,
|
||||
info: layer.getRowControls().info,
|
||||
};
|
||||
});
|
||||
const update = layer.update();
|
||||
const loadingInfo = layer.getRowControls().info;
|
||||
assert.match(loadingInfo, /Valid: [^\n]+ · loading/);
|
||||
await update;
|
||||
assert.equal(displayed.loading, true);
|
||||
assert.match(displayed.info, /Valid: [^\n]+ · preparing/);
|
||||
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
|
||||
ready = true;
|
||||
statusChanged();
|
||||
assert.equal(displayed.loading, false);
|
||||
assert.doesNotMatch(displayed.info, / · preparing| · loading/);
|
||||
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('weather summary describes the selected forecast and count remains numeric', async () => {
|
||||
const { layer } = harness({ getSnapshot: async () => complete('gfs') });
|
||||
await layer.update();
|
||||
assert.equal(typeof layer.getStats().count, 'number');
|
||||
assert.equal(layer.getStats().countLabel, 'Forecast');
|
||||
const summary = layer.getRowControls().summary;
|
||||
assert.equal(summary.label, 'Wind motion');
|
||||
assert.equal(summary.units, 'km/h');
|
||||
assert.match(summary.detail, /GFS forecast.*UTC/);
|
||||
assert.equal((summary.detail.match(/UTC/g) || []).length, 1);
|
||||
assert.equal(layer.getRowControls().chips.find(c => c.id === 'overlay-temperature').label, 'Temperature');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
|
||||
const nodes = [];
|
||||
let listener;
|
||||
let samples = 0;
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
};
|
||||
const viewer = {
|
||||
container,
|
||||
camera: { positionWC: {}, pickEllipsoid: () => { samples++; return { longitude: 0, latitude: 0 }; } },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 0, top: 0 }) },
|
||||
cartesianToCanvasCoordinates: () => ({ x: 400, y: 300 }),
|
||||
postRender: { addEventListener(fn) { listener = fn; return () => { listener = null; }; } },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const rendering = Object.fromEntries(['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map(name => [name, () => {}]));
|
||||
const layer = createWindLayer({
|
||||
feed: { getSnapshot: async ({ model }) => complete(model) },
|
||||
cesium: {
|
||||
Cartesian2: class {}, Ellipsoid: { WGS84: {} }, SceneMode: { SCENE3D: 3 },
|
||||
Cartographic: { fromCartesian: point => point }, Math: { toDegrees: value => value },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return true; } },
|
||||
},
|
||||
createRendering: () => rendering,
|
||||
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
layer.setParams({ inspect: true });
|
||||
const captured = layer.getRowControls().summary.reading;
|
||||
assert.equal(samples, 1);
|
||||
const retained = nodes[0];
|
||||
layer.setParams({ units: 'mph' });
|
||||
assert.equal(nodes[0], retained);
|
||||
const changed = layer.getRowControls().summary.reading;
|
||||
assert.equal(changed.speed, captured.speed);
|
||||
assert.equal(changed.coordinates, captured.coordinates);
|
||||
assert.match(changed.wind, /mph/);
|
||||
assert.equal(samples, 1, 'units do not resample');
|
||||
assert.equal(layer.getRowControls().summary.result.id, 'reading');
|
||||
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(typeof listener, 'function');
|
||||
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
|
||||
layer.setParams({ overlay: 'speed' });
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
|
||||
layer.setParams({ model: 'ifs' });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const resampled = layer.getRowControls().summary.reading;
|
||||
assert.equal(resampled.coordinates, captured.coordinates);
|
||||
assert.equal(resampled.model, 'ECMWF');
|
||||
assert.equal(resampled.position, captured.position);
|
||||
assert.equal(samples, 1, 'model and field changes never sample the moved camera');
|
||||
assert.match(layer.getRowControls().summary.result.lines.find(({ id }) => id === 'meta').text, /ECMWF · valid/);
|
||||
await layer.update();
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(layer.getRowControls().summary.reading.coordinates, '20.00°N · 70.00°E');
|
||||
assert.equal(samples, 2, 'the next explicit read samples the new center');
|
||||
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(nodes.length, 1);
|
||||
change();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(layer.getRowControls().summary.reading, null);
|
||||
assert.equal(layer.getRowControls().summary.result, null);
|
||||
assert.equal(listener, null);
|
||||
}
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('observed history labels wind as a forecast without changing its data or parameters', async () => {
|
||||
const { createWeatherClock } = await import('../weather/clock.js');
|
||||
const clock = createWeatherClock();
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
|
||||
let changes = 0;
|
||||
layer.setRowControlsListener(() => changes++);
|
||||
const params = layer.getParams();
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status');
|
||||
assert.ok(changes > 0);
|
||||
assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
|
||||
assert.deepEqual(layer.getParams(), params);
|
||||
await clock.latest();
|
||||
assert.equal(layer.getRowControls().summary.status, null);
|
||||
layer.destroy();
|
||||
const before = changes;
|
||||
await clock.setTarget('2026-09-14T12:00:00.000Z');
|
||||
assert.equal(changes, before);
|
||||
clock.destroy();
|
||||
});
|
||||
|
||||
test('wind unit chips appear only alongside a speed legend, including canvas trails', async () => {
|
||||
let renderMode = 'gpu-streamlines'; let imageryError = null;
|
||||
const layer = createWindLayer({ feed: { getSnapshot: async () => complete('gfs') }, createRendering: () => ({ attach() {}, start() {}, clear() {}, setField() {}, setOptions() {}, getDiagnostics: () => ({ renderMode, imageryError }), stop() {}, destroy() {} }) });
|
||||
layer.init({ container: {} }); layer.enable(); await layer.update();
|
||||
const unitChips = () => layer.getRowControls().chips.filter(({ id }) => id.startsWith('units-'));
|
||||
assert.equal(layer.getRowControls().readout, true); assert.equal(layer.getRowControls().summary.coverage, 'Global · 1° grid');
|
||||
assert.deepEqual(unitChips(), []); assert.deepEqual(layer.getRowControls().legend, []);
|
||||
renderMode = 'canvas-fallback'; assert.equal(unitChips().length, 3); assert.ok(layer.getRowControls().legend.length);
|
||||
renderMode = 'gpu-streamlines'; layer.setParams({ overlay: 'speed' }); assert.equal(unitChips().length, 3);
|
||||
imageryError = 'Unavailable'; assert.deepEqual(unitChips(), []); imageryError = null;
|
||||
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
|
||||
layer.destroy();
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { sampleWind, windSpeed } from './model.js';
|
||||
import { sampleScalar } from './fields.js';
|
||||
|
||||
const DIRECTIONS = [
|
||||
'N',
|
||||
'NNE',
|
||||
'NE',
|
||||
'ENE',
|
||||
'E',
|
||||
'ESE',
|
||||
'SE',
|
||||
'SSE',
|
||||
'S',
|
||||
'SSW',
|
||||
'SW',
|
||||
'WSW',
|
||||
'W',
|
||||
'WNW',
|
||||
'NW',
|
||||
'NNW',
|
||||
];
|
||||
export const WIND_UNITS = Object.freeze({
|
||||
'km/h': 3.6,
|
||||
'm/s': 1,
|
||||
mph: 2.2369362921,
|
||||
});
|
||||
|
||||
/** Meteorological bearing is where the wind comes FROM, not where it travels. */
|
||||
export function windFrom(u, v) {
|
||||
if (Math.hypot(u, v) < 0.2) return 'Calm';
|
||||
const bearing = ((Math.atan2(-u, -v) * 180) / Math.PI + 360) % 360;
|
||||
return DIRECTIONS[Math.round(bearing / 22.5) % 16];
|
||||
}
|
||||
|
||||
export function formatWindSpeed(speed, units = 'km/h') {
|
||||
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
|
||||
return `${(speed * WIND_UNITS[unit]).toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
/** A deliberate snapshot at the map center; does not own pointer input. */
|
||||
export function inspectWindAtCenter(
|
||||
snapshot,
|
||||
viewer,
|
||||
cesium,
|
||||
{ units, overlay, model, validTime, status, position } = {},
|
||||
) {
|
||||
const scene = viewer?.scene;
|
||||
const canvas = scene?.canvas;
|
||||
const ellipsoid = scene?.globe?.ellipsoid || cesium.Ellipsoid?.WGS84;
|
||||
const point =
|
||||
position ||
|
||||
(canvas &&
|
||||
viewer.camera?.pickEllipsoid?.(
|
||||
new cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
ellipsoid,
|
||||
));
|
||||
if (!point || !snapshot?.u || !snapshot?.v)
|
||||
return {
|
||||
coordinates: 'No surface reading',
|
||||
wind: 'Aim the map center at Earth',
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
'A location reading needs a loaded forecast and the Earth at the center of the view.',
|
||||
};
|
||||
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
|
||||
const lon = cesium.Math.toDegrees(location.longitude);
|
||||
const lat = cesium.Math.toDegrees(location.latitude);
|
||||
const vector = sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
lon,
|
||||
lat,
|
||||
);
|
||||
const speed = windSpeed(vector.u, vector.v);
|
||||
const from = windFrom(vector.u, vector.v);
|
||||
const scalar = ['temperature', 'pressure'].includes(overlay)
|
||||
? sampleScalar(snapshot, lon, lat, overlay)
|
||||
: null;
|
||||
return {
|
||||
position: point,
|
||||
speed,
|
||||
from,
|
||||
units,
|
||||
coordinates: `${Math.abs(lat).toFixed(2)}°${lat < 0 ? 'S' : 'N'} · ${Math.abs(lon).toFixed(2)}°${lon < 0 ? 'W' : 'E'}`,
|
||||
wind: `${formatWindSpeed(speed, units)}${from === 'Calm' ? ' · calm' : ` from ${from}`}`,
|
||||
scalarKind: overlay,
|
||||
scalarLabel:
|
||||
overlay === 'speed'
|
||||
? 'Wind speed'
|
||||
: overlay === 'temperature'
|
||||
? 'Air temperature · 2 m'
|
||||
: overlay === 'pressure'
|
||||
? 'Sea-level pressure'
|
||||
: null,
|
||||
scalarValue:
|
||||
overlay === 'speed'
|
||||
? formatWindSpeed(speed, units)
|
||||
: scalar == null
|
||||
? null
|
||||
: `${scalar.toFixed(1)} ${overlay === 'temperature' ? '°C' : 'hPa'}`,
|
||||
model,
|
||||
validTime,
|
||||
status,
|
||||
explanation:
|
||||
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
|
||||
};
|
||||
}
|
||||
|
||||
/** One passive marker for the captured location; it never samples on camera movement. */
|
||||
export function createWindInspectionMarker({ container, viewer, cesium }) {
|
||||
let removeRender = null;
|
||||
let marker = null;
|
||||
const clear = () => {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
marker?.remove();
|
||||
marker = null;
|
||||
viewer?.scene?.requestRender?.();
|
||||
};
|
||||
return {
|
||||
show(position) {
|
||||
clear();
|
||||
const scene = viewer?.scene;
|
||||
if (
|
||||
!position ||
|
||||
!container?.ownerDocument?.createElement ||
|
||||
!scene?.postRender?.addEventListener ||
|
||||
!scene.cartesianToCanvasCoordinates
|
||||
)
|
||||
return;
|
||||
marker = container.ownerDocument.createElement('div');
|
||||
marker.className = 'gev-wind-inspection-marker';
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
marker.style.cssText =
|
||||
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
|
||||
container.appendChild(marker);
|
||||
const occluder = new cesium.EllipsoidalOccluder(
|
||||
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const render = () => {
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = scene.cartesianToCanvasCoordinates(position);
|
||||
const visible =
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= scene.canvas.clientWidth &&
|
||||
point.y <= scene.canvas.clientHeight &&
|
||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
||||
occluder.isPointVisible(position));
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
|
||||
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
|
||||
};
|
||||
removeRender = scene.postRender.addEventListener(render);
|
||||
render();
|
||||
scene.requestRender?.();
|
||||
},
|
||||
clear,
|
||||
destroy: clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
windFrom,
|
||||
formatWindSpeed,
|
||||
inspectWindAtCenter,
|
||||
} from './inspection.js';
|
||||
|
||||
test('wind directions describe the source of the flow and calm is not north', () => {
|
||||
assert.equal(windFrom(10, 0), 'W');
|
||||
assert.equal(windFrom(-10, 0), 'E');
|
||||
assert.equal(windFrom(0, 10), 'S');
|
||||
assert.equal(windFrom(0, -10), 'N');
|
||||
assert.equal(windFrom(0, 0), 'Calm');
|
||||
assert.equal(formatWindSpeed(10, 'km/h'), '36.0 km/h');
|
||||
assert.equal(formatWindSpeed(10, 'mph'), '22.4 mph');
|
||||
});
|
||||
test('center inspection uses a fixed forecast reading without double-converting scalar units', () => {
|
||||
const snapshot = {
|
||||
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
u: Float32Array.from([10, 10, 10, 10]),
|
||||
v: new Float32Array(4),
|
||||
scalar: {
|
||||
kind: 'temperature',
|
||||
units: '°C',
|
||||
values: Float32Array.from([20, 20, 20, 20]),
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
camera: { pickEllipsoid: () => ({ longitude: 0, latitude: 0 }) },
|
||||
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
|
||||
};
|
||||
const cesium = {
|
||||
Cartesian2: class {
|
||||
constructor(x, y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
},
|
||||
Ellipsoid: { WGS84: {} },
|
||||
Cartographic: { fromCartesian: (p) => p },
|
||||
Math: { toDegrees: (x) => (x * 180) / Math.PI },
|
||||
};
|
||||
const result = inspectWindAtCenter(snapshot, viewer, cesium, {
|
||||
overlay: 'temperature',
|
||||
units: 'km/h',
|
||||
model: 'GFS',
|
||||
validTime: 'fixed',
|
||||
status: 'forecast',
|
||||
});
|
||||
assert.equal(result.wind, '36.0 km/h from W');
|
||||
assert.equal(result.scalarValue, '20.0 °C');
|
||||
assert.equal(result.validTime, 'fixed');
|
||||
assert.equal(result.coordinates, '0.00°N · 0.00°E');
|
||||
viewer.camera.pickEllipsoid = () => null;
|
||||
assert.equal(
|
||||
inspectWindAtCenter(snapshot, viewer, cesium).coordinates,
|
||||
'No surface reading',
|
||||
);
|
||||
});
|
||||
|
||||
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
|
||||
const { createWindInspectionMarker } = await import('./inspection.js');
|
||||
let listener = null;
|
||||
let visible = true;
|
||||
const projected = [];
|
||||
const nodes = [];
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 5, top: 10 }),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { positionWC: {} },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
|
||||
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
|
||||
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const cesium = {
|
||||
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
|
||||
};
|
||||
const owner = createWindInspectionMarker({ container, viewer, cesium });
|
||||
const point = { x: 1, y: 2, z: 3 };
|
||||
owner.show(point);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.match(nodes[0].style.cssText, /pointer-events:none/);
|
||||
assert.equal(nodes[0].style.left, '110px');
|
||||
assert.equal(nodes[0].style.top, '220px');
|
||||
viewer.camera.positionWC = { moved: true };
|
||||
visible = false;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, true);
|
||||
assert.ok(projected.every((value) => value === point));
|
||||
visible = true;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, false);
|
||||
owner.show({ x: 4 });
|
||||
assert.equal(nodes.length, 1);
|
||||
owner.clear();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
owner.show(point);
|
||||
owner.destroy();
|
||||
owner.destroy();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/** Normalize a longitude into the half-open world interval. */
|
||||
export function normalizeLongitude(lon) {
|
||||
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||
}
|
||||
|
||||
function interpolate(array, a, b, c, d, tx, ty) {
|
||||
return (
|
||||
(array[a] * (1 - tx) + array[b] * tx) * (1 - ty) +
|
||||
(array[c] * (1 - tx) + array[d] * tx) * ty
|
||||
);
|
||||
}
|
||||
|
||||
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
|
||||
export function sampleWind(field, lon, lat, result = {}) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
|
||||
result.u = 0;
|
||||
result.v = 0;
|
||||
return result;
|
||||
}
|
||||
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
|
||||
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
|
||||
const y = (la1 - lat) / dy;
|
||||
const x0 = Math.floor(x);
|
||||
const tx = x - x0;
|
||||
const yClamped = Math.max(0, Math.min(ny - 1, y));
|
||||
const y0 = Math.floor(yClamped);
|
||||
const y1 = Math.min(ny - 1, y0 + 1);
|
||||
const ty = yClamped - y0;
|
||||
const left = ((x0 % nx) + nx) % nx;
|
||||
const right = (left + 1) % nx;
|
||||
const a = y0 * nx + left;
|
||||
const b = y0 * nx + right;
|
||||
const c = y1 * nx + left;
|
||||
const d = y1 * nx + right;
|
||||
result.u = interpolate(u, a, b, c, d, tx, ty);
|
||||
result.v = interpolate(v, a, b, c, d, tx, ty);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return metres represented by one longitude degree at a latitude. */
|
||||
export function metersPerDegreeLon(lat) {
|
||||
return 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
}
|
||||
|
||||
/** Calculate wind magnitude. */
|
||||
export function windSpeed(u, v) {
|
||||
return Math.hypot(u, v);
|
||||
}
|
||||
|
||||
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
|
||||
export function windColor(speed, { max = 30 } = {}) {
|
||||
const stops = [
|
||||
'#1e3a8a',
|
||||
'#2563eb',
|
||||
'#22d3ee',
|
||||
'#34d399',
|
||||
'#fbbf24',
|
||||
'#f97316',
|
||||
'#ef4444',
|
||||
];
|
||||
const t = Math.max(0, Math.min(1, speed / max));
|
||||
const position = t * (stops.length - 1);
|
||||
const index = Math.min(stops.length - 2, Math.floor(position));
|
||||
const fraction = position - index;
|
||||
if (t === 1) return stops.at(-1);
|
||||
const parse = (color) =>
|
||||
color.match(/\w\w/g).map((part) => parseInt(part, 16));
|
||||
const a = parse(stops[index]);
|
||||
const b = parse(stops[index + 1]);
|
||||
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
|
||||
}
|
||||
|
||||
/** Advance a particle by a wind vector, mutating and returning it. */
|
||||
export function advectParticle(
|
||||
particle,
|
||||
wind,
|
||||
dtSeconds,
|
||||
{ speedScale = 1 } = {},
|
||||
) {
|
||||
particle.lon = normalizeLongitude(
|
||||
particle.lon +
|
||||
(wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
|
||||
);
|
||||
particle.lat = Math.max(
|
||||
-89,
|
||||
Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320),
|
||||
);
|
||||
return particle;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { advectParticle, sampleWind, windColor } from './model.js';
|
||||
|
||||
const field = {
|
||||
u: Float32Array.from([1, 2, 3, 4]),
|
||||
v: Float32Array.from([5, 6, 7, 8]),
|
||||
nx: 2,
|
||||
ny: 2,
|
||||
lo1: 0,
|
||||
la1: 90,
|
||||
dx: 180,
|
||||
dy: 90,
|
||||
};
|
||||
|
||||
test('sampleWind interpolates, wraps, clamps, and rejects non-finite input', () => {
|
||||
assert.deepEqual(sampleWind(field, 0, 90), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, 90, 45), { u: 2.5, v: 6.5 });
|
||||
assert.deepEqual(sampleWind(field, 359.99, 90), sampleWind(field, -0.01, 90));
|
||||
assert.deepEqual(sampleWind(field, 0, 100), { u: 1, v: 5 });
|
||||
assert.deepEqual(sampleWind(field, Infinity, 0), { u: 0, v: 0 });
|
||||
});
|
||||
|
||||
test('advectParticle moves and constrains particles', () => {
|
||||
const particle = { lon: 179, lat: 88 };
|
||||
advectParticle(particle, { u: 10, v: 100 }, 3600);
|
||||
assert.ok(particle.lon < 0);
|
||||
assert.equal(particle.lat, 89);
|
||||
});
|
||||
|
||||
test('windColor returns a monotonic ramp string', () => {
|
||||
assert.equal(typeof windColor(0), 'string');
|
||||
assert.notEqual(windColor(0), windColor(30));
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
|
||||
|
||||
/** Reformat a captured sample; changing units never samples the map again. */
|
||||
export function formatWindReading(reading, units) {
|
||||
if (!reading) return null;
|
||||
return {
|
||||
...reading,
|
||||
units,
|
||||
scalarValue:
|
||||
reading.scalarKind === 'speed'
|
||||
? formatWindSpeed(reading.speed, units)
|
||||
: reading.scalarValue,
|
||||
wind: Number.isFinite(reading.speed)
|
||||
? `${formatWindSpeed(reading.speed, units)}${reading.from === 'Calm' ? ' · calm' : ` from ${reading.from}`}`
|
||||
: reading.wind,
|
||||
};
|
||||
}
|
||||
|
||||
export function windUnitChips(units) {
|
||||
return Object.keys(WIND_UNITS).map((value) => ({
|
||||
id: `units-${value}`,
|
||||
label: value,
|
||||
active: units === value,
|
||||
params: { units: value },
|
||||
title: 'Wind speed units',
|
||||
}));
|
||||
}
|
||||
|
||||
/** Portable result block consumed by the WEATHER card. */
|
||||
export function windReadingResult(reading) {
|
||||
return {
|
||||
id: 'reading',
|
||||
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
|
||||
lines: [
|
||||
{
|
||||
id: 'wind',
|
||||
text: reading.wind,
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
|
||||
},
|
||||
...(reading.scalarValue
|
||||
? [
|
||||
{
|
||||
id: 'scalar',
|
||||
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ id: 'explanation', text: reading.explanation },
|
||||
],
|
||||
clear: { params: { inspect: false } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatWindReading, windReadingResult } from './presentation.js';
|
||||
|
||||
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
|
||||
test('captured reading reformats without changing its sample and produces a portable result block', () => {
|
||||
const reading = formatWindReading(sample, 'mph');
|
||||
assert.equal(reading.wind, '11.2 mph from SW');
|
||||
assert.equal(reading.speed, sample.speed);
|
||||
assert.equal(reading.coordinates, sample.coordinates);
|
||||
assert.equal(sample.units, undefined);
|
||||
const section = windReadingResult(reading);
|
||||
assert.equal(section.id, 'reading');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
|
||||
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
|
||||
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
|
||||
assert.deepEqual(section.clear.params, { inspect: false });
|
||||
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
|
||||
assert.equal(formatWindReading(null, 'km/h'), null);
|
||||
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
// View-directed display lighting over existing terrain normals. This does not
|
||||
// represent the Sun, change terrain geometry, or supply missing elevation data.
|
||||
function reliefSource(terrainNormals) {
|
||||
const normal = terrainNormals
|
||||
? 'vec3 normal = normalize(materialInput.normalEC);'
|
||||
: `vec3 worldPosition = (czm_inverseView * vec4(-materialInput.positionToEyeEC, 1.0)).xyz;
|
||||
vec3 geodeticNormal = normalize(worldPosition * czm_ellipsoidInverseRadii * czm_ellipsoidInverseRadii);
|
||||
vec3 normal = normalize(czm_viewRotation * geodeticNormal);`;
|
||||
return `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput)
|
||||
{
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
${normal}
|
||||
vec3 displayLight = normalize(vec3(-0.45, 0.55, 0.8));
|
||||
float shade = clamp((1.0 - dot(normal, displayLight)) * 0.32, 0.0, 0.28);
|
||||
material.diffuse = vec3(0.0);
|
||||
material.alpha = shade;
|
||||
return material;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Optional, exclusive globe-material owner; no animation or source acquisition. */
|
||||
export function createWindRelief({ cesium, getViewer } = {}) {
|
||||
let globe = null;
|
||||
let scene = null;
|
||||
let owned = null;
|
||||
let previous = undefined;
|
||||
let destroyed = false;
|
||||
let reason = 'Not attached';
|
||||
let mode = null;
|
||||
const alive = (value) => value && !value.isDestroyed?.();
|
||||
const active = () =>
|
||||
Boolean(owned && alive(globe) && globe.material === owned);
|
||||
|
||||
function clear() {
|
||||
const changed = active();
|
||||
if (changed) globe.material = previous;
|
||||
if (owned && !owned.isDestroyed?.()) owned.destroy?.();
|
||||
if (changed && alive(scene)) scene.requestRender?.();
|
||||
owned = null;
|
||||
mode = null;
|
||||
globe = null;
|
||||
scene = null;
|
||||
previous = undefined;
|
||||
reason = destroyed ? 'Destroyed' : 'Not attached';
|
||||
}
|
||||
|
||||
return {
|
||||
attach() {
|
||||
if (destroyed) return false;
|
||||
const viewer = getViewer?.();
|
||||
const nextScene = alive(viewer) ? viewer.scene : null;
|
||||
const nextGlobe = nextScene?.globe;
|
||||
const nextMode =
|
||||
nextGlobe?.terrainProvider?.hasVertexNormals === true
|
||||
? 'terrain relief'
|
||||
: 'globe curvature';
|
||||
if (active() && globe === nextGlobe && mode === nextMode) return true;
|
||||
clear();
|
||||
if (!alive(nextGlobe) || typeof cesium?.Material !== 'function') {
|
||||
reason = 'Globe material unavailable';
|
||||
return false;
|
||||
}
|
||||
if (nextGlobe.material != null) {
|
||||
reason = 'Globe material already owned';
|
||||
return false;
|
||||
}
|
||||
// Cesium excludes normal-dependent materials without terrain normals.
|
||||
// The fallback uses existing position/ellipsoid uniforms: curvature only,
|
||||
// never an invented terrain normal or an additional provider request.
|
||||
try {
|
||||
owned = new cesium.Material({
|
||||
fabric: {
|
||||
type:
|
||||
nextMode === 'terrain relief'
|
||||
? 'GEVWindTerrainRelief'
|
||||
: 'GEVWindGlobeCurvature',
|
||||
source: reliefSource(nextMode === 'terrain relief'),
|
||||
},
|
||||
translucent: true,
|
||||
});
|
||||
mode = nextMode;
|
||||
globe = nextGlobe;
|
||||
scene = nextScene;
|
||||
previous = globe.material;
|
||||
globe.material = owned;
|
||||
scene.requestRender?.();
|
||||
reason = null;
|
||||
return true;
|
||||
} catch {
|
||||
clear();
|
||||
reason = 'Relief material unavailable';
|
||||
return false;
|
||||
}
|
||||
},
|
||||
clear,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clear();
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
active: active(),
|
||||
reason: owned && !active() ? 'Globe material replaced' : reason,
|
||||
lighting: 'View-directed display shading',
|
||||
mode: active() ? mode : null,
|
||||
maxOpacity: 0.28,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindRelief } from './relief.js';
|
||||
|
||||
function fixture({ material = undefined, normals = true } = {}) {
|
||||
const materials = [];
|
||||
class Material {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.destroyed = false;
|
||||
materials.push(this);
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
destroy() {
|
||||
assert.equal(this.destroyed, false);
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
let renders = 0;
|
||||
const globe = { material, terrainProvider: { hasVertexNormals: normals } };
|
||||
const scene = { globe, requestRender: () => renders++ };
|
||||
const viewer = { scene };
|
||||
const relief = createWindRelief({
|
||||
cesium: { Material },
|
||||
getViewer: () => viewer,
|
||||
});
|
||||
return { relief, globe, scene, viewer, materials, renders: () => renders };
|
||||
}
|
||||
|
||||
test('relief acquires once and restores the exact prior empty material on clear', () => {
|
||||
for (const material of [null, undefined]) {
|
||||
const f = fixture({ material });
|
||||
f.globe.material = material;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.materials.length, 1);
|
||||
assert.equal(f.globe.material, f.materials[0]);
|
||||
assert.equal(f.relief.getDiagnostics().active, true);
|
||||
f.relief.clear();
|
||||
assert.equal(f.globe.material, material);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.renders(), 2);
|
||||
f.relief.clear();
|
||||
assert.equal(f.renders(), 2);
|
||||
assert.equal(f.relief.attach(), true);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.materials[1].destroyed, true);
|
||||
assert.equal(f.relief.attach(), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('pre-existing and replacement material owners are never overwritten', () => {
|
||||
const other = { name: 'other-owner' };
|
||||
const f = fixture({ material: other });
|
||||
assert.equal(f.relief.attach(), false);
|
||||
assert.equal(f.globe.material, other);
|
||||
assert.equal(f.materials.length, 0);
|
||||
f.globe.material = undefined;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
f.globe.material = other;
|
||||
assert.equal(f.relief.getDiagnostics().reason, 'Globe material replaced');
|
||||
f.relief.clear();
|
||||
assert.equal(f.globe.material, other);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.relief.attach(), false);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.globe.material, other);
|
||||
});
|
||||
|
||||
test('missing terrain normals uses curvature only and upgrades when normals exist', () => {
|
||||
const f = fixture({ normals: false });
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.relief.getDiagnostics().mode, 'globe curvature');
|
||||
const source = f.materials[0].options.fabric.source;
|
||||
assert.doesNotMatch(source, /normalEC|slope/);
|
||||
assert.match(source, /materialInput.positionToEyeEC/);
|
||||
assert.match(source, /czm_ellipsoidInverseRadii/);
|
||||
f.globe.terrainProvider.hasVertexNormals = true;
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(f.relief.getDiagnostics().mode, 'terrain relief');
|
||||
assert.match(f.materials[1].options.fabric.source, /materialInput.normalEC/);
|
||||
f.relief.destroy();
|
||||
assert.equal(f.globe.material, undefined);
|
||||
});
|
||||
|
||||
test('moving to a new viewer releases the old globe and owns only the new one', () => {
|
||||
const f = fixture();
|
||||
f.relief.attach();
|
||||
const old = f.globe;
|
||||
const next = { material: null, terrainProvider: { hasVertexNormals: true } };
|
||||
f.viewer.scene = { globe: next, requestRender() {} };
|
||||
assert.equal(f.relief.attach(), true);
|
||||
assert.equal(old.material, undefined);
|
||||
assert.equal(f.materials[0].destroyed, true);
|
||||
assert.equal(next.material, f.materials[1]);
|
||||
f.relief.destroy();
|
||||
assert.equal(next.material, null);
|
||||
});
|
||||
@@ -0,0 +1,801 @@
|
||||
import {
|
||||
createShellSurface,
|
||||
WEATHER_SHELL_HEIGHTS,
|
||||
} from '../weather/shellRendering.js';
|
||||
import { createWindRelief } from './relief.js';
|
||||
import { orderWeatherImagery } from '../weather/imageryOrder.js';
|
||||
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
|
||||
import { createWindGpuRendering } from './gpuRendering.js';
|
||||
import { advectParticle, sampleWind } from './model.js';
|
||||
import {
|
||||
createFieldRaster,
|
||||
trailEraseAlpha,
|
||||
windTrailColor,
|
||||
} from './fields.js';
|
||||
|
||||
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
|
||||
const WIND_FIELD_FADE_LOW_METERS = 200_000;
|
||||
const WIND_FIELD_LOG_RANGE = Math.log(
|
||||
WIND_FIELD_FADE_HIGH_METERS / WIND_FIELD_FADE_LOW_METERS,
|
||||
);
|
||||
|
||||
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
|
||||
// (which include the scalar overlay) or only cycle IDs (which can be revised).
|
||||
// This bounded scan runs on acquisition, never in the animation loop.
|
||||
function sameWind(previous, next, previousField, nextField) {
|
||||
if (!previous || !next || !previousField || !nextField) return false;
|
||||
for (const key of ['model', 'level', 'units'])
|
||||
if (previous[key] !== next[key]) return false;
|
||||
for (const key of ['runIso', 'validIso', 'forecastHour', 'date', 'hour'])
|
||||
if (previous.cycle?.[key] !== next.cycle?.[key]) return false;
|
||||
for (const key of ['nx', 'ny', 'lo1', 'la1', 'dx', 'dy'])
|
||||
if (previousField[key] !== nextField[key]) return false;
|
||||
for (const key of ['u', 'v']) {
|
||||
const before = previousField[key];
|
||||
const after = nextField[key];
|
||||
if (!before || !after || before.length !== after.length) return false;
|
||||
if (before === after) continue;
|
||||
for (let i = 0; i < before.length; i++)
|
||||
if (before[i] !== after[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Globe-projected surface flow plus one owned, static colour field: globe
|
||||
* imagery on the globe host, a raised shell on the 3D Tiles host. */
|
||||
export function createWindRendering({
|
||||
cesium,
|
||||
container,
|
||||
getViewer,
|
||||
getHost = () => ({
|
||||
collection:
|
||||
getViewer?.()?.imageryLayers ?? getViewer?.()?.scene?.imageryLayers,
|
||||
kind: 'globe',
|
||||
}),
|
||||
eventTarget = globalThis.window,
|
||||
createGpuRendering = createWindGpuRendering,
|
||||
onStatusChange,
|
||||
} = {}) {
|
||||
let canvas = null;
|
||||
let context = null;
|
||||
let snapshot = null;
|
||||
let field = null;
|
||||
let particles = [];
|
||||
let frame = null;
|
||||
let running = false;
|
||||
let lastTime = null;
|
||||
let cssWidth = 1;
|
||||
let cssHeight = 1;
|
||||
let warned = false;
|
||||
let overlay = 'none';
|
||||
let paused = false;
|
||||
let reducedMotion = false;
|
||||
let imagery = null;
|
||||
let shell = null;
|
||||
let imageryCollection = null;
|
||||
let imageryKind = null;
|
||||
let imageryError = null;
|
||||
let imageryErrorRemove = null;
|
||||
let media = null;
|
||||
let removers = [];
|
||||
let bounds = null;
|
||||
let cameraChanged = true;
|
||||
let painted = 0;
|
||||
const gpu = createGpuRendering({ cesium, getViewer });
|
||||
let gpuActive = false;
|
||||
let gpuVisible = false;
|
||||
let reportedGpuReady = null;
|
||||
let gpuNarrow = false;
|
||||
let flowTime = 0;
|
||||
const relief = createWindRelief({ cesium, getViewer });
|
||||
const signature = Array(8).fill(null);
|
||||
const wind = { u: 0, v: 0 };
|
||||
const before = { lon: 0, lat: 0 };
|
||||
const world =
|
||||
typeof cesium.Cartesian3 === 'function' ? new cesium.Cartesian3() : {};
|
||||
const screenA =
|
||||
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
|
||||
const screenB =
|
||||
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
|
||||
const degree = Math.PI / 180;
|
||||
const hidden = () => globalThis.document?.hidden === true;
|
||||
const still = () => paused || reducedMotion;
|
||||
const budget = () =>
|
||||
Math.max(
|
||||
200,
|
||||
Math.min(
|
||||
cssWidth < 600 ? 1000 : 3000,
|
||||
Math.floor((cssWidth * cssHeight) / 420),
|
||||
),
|
||||
);
|
||||
const viewerReady = () => {
|
||||
const viewer = getViewer?.();
|
||||
return viewer && !viewer.isDestroyed?.() ? viewer : null;
|
||||
};
|
||||
|
||||
function cancel() {
|
||||
if (frame !== null) globalThis.cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
}
|
||||
function schedule() {
|
||||
if (
|
||||
running &&
|
||||
field &&
|
||||
canvas &&
|
||||
!hidden() &&
|
||||
!still() &&
|
||||
(!gpuActive || gpuVisible) &&
|
||||
frame === null
|
||||
)
|
||||
frame = globalThis.requestAnimationFrame(draw);
|
||||
}
|
||||
function clearPixels() {
|
||||
context?.clearRect(0, 0, cssWidth, cssHeight);
|
||||
}
|
||||
|
||||
function resize(viewer) {
|
||||
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
|
||||
cssWidth =
|
||||
viewer?.scene?.canvas?.clientWidth || container?.clientWidth || 1;
|
||||
cssHeight =
|
||||
viewer?.scene?.canvas?.clientHeight || container?.clientHeight || 1;
|
||||
const width = Math.max(1, Math.floor(cssWidth * ratio));
|
||||
const height = Math.max(1, Math.floor(cssHeight * ratio));
|
||||
// Assigning even unchanged dimensions erases a real canvas.
|
||||
if (canvas.width === width && canvas.height === height) return false;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkCamera(scene) {
|
||||
const camera = scene.camera;
|
||||
const position = camera?.positionWC;
|
||||
const values = [
|
||||
position?.x,
|
||||
position?.y,
|
||||
position?.z,
|
||||
camera?.heading,
|
||||
camera?.pitch,
|
||||
camera?.roll,
|
||||
cssWidth,
|
||||
cssHeight,
|
||||
];
|
||||
let changed = false;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (values[i] !== signature[i]) changed = true;
|
||||
signature[i] = values[i];
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function updateBounds(scene) {
|
||||
bounds = null;
|
||||
if (!scene.camera?.computeViewRectangle || !cesium.Ellipsoid) return;
|
||||
try {
|
||||
const rect = scene.camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
|
||||
if (!rect) return;
|
||||
const west = rect.west / degree;
|
||||
let east = rect.east / degree;
|
||||
const south = Math.max(-89, rect.south / degree);
|
||||
const north = Math.min(89, rect.north / degree);
|
||||
if (east < west) east += 360;
|
||||
if (
|
||||
[west, east, south, north].every(Number.isFinite) &&
|
||||
east > west &&
|
||||
north > south
|
||||
)
|
||||
bounds = {
|
||||
west,
|
||||
east,
|
||||
sinSouth: Math.sin(south * degree),
|
||||
sinNorth: Math.sin(north * degree),
|
||||
};
|
||||
} catch {
|
||||
/* A sky-facing camera can have no earth rectangle. */
|
||||
}
|
||||
}
|
||||
|
||||
function makeOccluder(scene) {
|
||||
if (
|
||||
!cesium.EllipsoidalOccluder ||
|
||||
!cesium.Ellipsoid ||
|
||||
!scene.camera?.positionWC
|
||||
)
|
||||
return null;
|
||||
return new cesium.EllipsoidalOccluder(
|
||||
cesium.Ellipsoid.WGS84,
|
||||
scene.camera.positionWC,
|
||||
);
|
||||
}
|
||||
|
||||
function project(scene, occluder, lon, lat, result) {
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
const point = cesium.Cartesian3.fromDegrees(
|
||||
lon,
|
||||
lat,
|
||||
0,
|
||||
cesium.Ellipsoid?.WGS84,
|
||||
world,
|
||||
);
|
||||
if (occluder && !occluder.isPointVisible(point)) return null;
|
||||
const projected = cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
point,
|
||||
result,
|
||||
);
|
||||
if (
|
||||
!projected ||
|
||||
!Number.isFinite(projected.x) ||
|
||||
!Number.isFinite(projected.y)
|
||||
)
|
||||
return null;
|
||||
// A perspective surface normal dims the limb instead of accumulating a bright rim.
|
||||
const camera = scene.camera?.positionWC;
|
||||
const dx = camera?.x - point.x;
|
||||
const dy = camera?.y - point.y;
|
||||
const dz = camera?.z - point.z;
|
||||
const denominator =
|
||||
Math.hypot(point.x, point.y, point.z) * Math.hypot(dx, dy, dz);
|
||||
const cosine =
|
||||
denominator > 0
|
||||
? (point.x * dx + point.y * dy + point.z * dz) / denominator
|
||||
: 1;
|
||||
projected.visibility = Number.isFinite(cosine)
|
||||
? Math.max(0, Math.min(1, cosine / 0.3))
|
||||
: 1;
|
||||
return projected;
|
||||
}
|
||||
const inView = (point) =>
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= cssWidth &&
|
||||
point.y <= cssHeight;
|
||||
|
||||
function spawn(particle, scene, occluder) {
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const west = bounds?.west ?? -180;
|
||||
const east = bounds?.east ?? 180;
|
||||
const low = bounds?.sinSouth ?? -0.9998;
|
||||
const high = bounds?.sinNorth ?? 0.9998;
|
||||
particle.lon = ((west + Math.random() * (east - west) + 540) % 360) - 180;
|
||||
particle.lat = Math.asin(low + Math.random() * (high - low)) / degree;
|
||||
particle.age = 1 + Math.random() * 7;
|
||||
particle.life = 8 + Math.random() * 4;
|
||||
if (inView(project(scene, occluder, particle.lon, particle.lat, screenA)))
|
||||
return;
|
||||
}
|
||||
}
|
||||
function seed(scene, occluder) {
|
||||
const count = budget();
|
||||
if (particles.length > count) particles.length = count;
|
||||
while (particles.length < count)
|
||||
particles.push({ lon: 0, lat: 0, age: 0, life: 10 });
|
||||
updateBounds(scene);
|
||||
for (const particle of particles) spawn(particle, scene, occluder);
|
||||
}
|
||||
|
||||
function removeImagery() {
|
||||
imageryErrorRemove?.();
|
||||
imageryErrorRemove = null;
|
||||
if (imagery && imageryCollection && !imageryCollection.isDestroyed?.())
|
||||
imageryCollection.remove(imagery, true);
|
||||
else if (imagery && !imagery.isDestroyed?.()) imagery.destroy?.();
|
||||
imagery = null;
|
||||
shell?.destroy();
|
||||
shell = null;
|
||||
imageryCollection = null;
|
||||
imageryKind = null;
|
||||
}
|
||||
function updateImageryFade(camera, settled = false) {
|
||||
// The tileset shell keeps the quantised move-end fade; globe imagery keeps
|
||||
// its smooth per-frame fade.
|
||||
if (
|
||||
(!imagery && !shell) ||
|
||||
!camera?.positionCartographic ||
|
||||
(imageryKind === 'tileset' && !settled)
|
||||
)
|
||||
return;
|
||||
const height = camera.positionCartographic.height;
|
||||
const fade = !gpuActive
|
||||
? 1
|
||||
: height <= WIND_FIELD_FADE_LOW_METERS
|
||||
? 0
|
||||
: height >= WIND_FIELD_FADE_HIGH_METERS
|
||||
? 1
|
||||
: Math.log(height / WIND_FIELD_FADE_LOW_METERS) /
|
||||
WIND_FIELD_LOG_RANGE;
|
||||
const baseAlpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
const smoothAlpha = baseAlpha * fade;
|
||||
const alpha =
|
||||
gpuActive && imageryKind === 'tileset'
|
||||
? Math.round(smoothAlpha * 10) / 10
|
||||
: smoothAlpha;
|
||||
if (shell) {
|
||||
shell.setAlpha(alpha);
|
||||
shell.setShow(alpha > 0 && getHost().kind !== 'none');
|
||||
return;
|
||||
}
|
||||
if (Math.abs(imagery.alpha - alpha) > 0.005) imagery.alpha = alpha;
|
||||
const show = alpha > 0;
|
||||
if (imagery.show !== show) imagery.show = show;
|
||||
}
|
||||
function installImagery() {
|
||||
removeImagery();
|
||||
imageryError = null;
|
||||
const viewer = viewerReady();
|
||||
if (!snapshot || overlay === 'none' || !viewer) return;
|
||||
const { collection, kind } = getHost();
|
||||
if (kind === 'none') {
|
||||
imageryError = NO_IMAGERY_HOST;
|
||||
return;
|
||||
}
|
||||
const raster = createFieldRaster(snapshot, overlay);
|
||||
if (!raster) {
|
||||
imageryError = `${overlay} field unavailable`;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
kind === 'tileset'
|
||||
? !cesium.Primitive
|
||||
: !collection || !cesium.SingleTileImageryProvider
|
||||
) {
|
||||
imageryError = 'Globe imagery unavailable';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const texture = document.createElement('canvas');
|
||||
texture.width = raster.width;
|
||||
texture.height = raster.height;
|
||||
const ctx = texture.getContext('2d');
|
||||
const pixels = ctx.createImageData(raster.width, raster.height);
|
||||
pixels.data.set(raster.rgba);
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
if (kind === 'tileset') {
|
||||
shell = createShellSurface({
|
||||
viewer,
|
||||
cesium,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
height: WEATHER_SHELL_HEIGHTS.wind,
|
||||
});
|
||||
shell.setImage(texture);
|
||||
imageryKind = kind;
|
||||
shell.setAlpha(overlay === 'temperature' ? 1 : 0.85);
|
||||
updateImageryFade(viewer.scene.camera, true);
|
||||
viewer.scene?.requestRender?.();
|
||||
return;
|
||||
}
|
||||
const provider = new cesium.SingleTileImageryProvider({
|
||||
url: texture.toDataURL('image/png'),
|
||||
tileWidth: raster.width,
|
||||
tileHeight: raster.height,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
});
|
||||
imagery = collection.addImageryProvider(provider);
|
||||
orderWeatherImagery(collection, imagery, 0);
|
||||
imageryCollection = collection;
|
||||
imageryKind = kind;
|
||||
// Temperature colors carry quantitative meaning; double transparency
|
||||
// blends orange heat into blue ocean and obscures useful gradients.
|
||||
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
|
||||
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
|
||||
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
|
||||
imageryError = 'Globe field image unavailable';
|
||||
});
|
||||
viewer.scene?.requestRender?.();
|
||||
} catch {
|
||||
removeImagery();
|
||||
imageryError = 'Globe field image unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
function rehome() {
|
||||
if (!snapshot || overlay === 'none') return;
|
||||
const { collection, kind } = getHost();
|
||||
const wasHidden = imageryError === NO_IMAGERY_HOST;
|
||||
if ((imagery || shell) && kind !== 'none' && kind !== imageryKind) {
|
||||
installImagery();
|
||||
onStatusChange?.();
|
||||
return;
|
||||
}
|
||||
// A host without imagery hides the retained shell until a host returns.
|
||||
if (shell && kind === 'none') shell.setShow(false);
|
||||
if (imagery && collection !== imageryCollection) {
|
||||
imageryCollection?.remove(imagery, false);
|
||||
imageryCollection = collection;
|
||||
if (collection) {
|
||||
collection.add(imagery);
|
||||
orderWeatherImagery(collection, imagery, 0);
|
||||
}
|
||||
viewerReady()?.scene?.requestRender?.();
|
||||
}
|
||||
if ((gpuActive || kind === 'tileset') && kind !== 'none')
|
||||
updateImageryFade(viewerReady()?.scene?.camera, true);
|
||||
if (kind === 'none') imageryError = NO_IMAGERY_HOST;
|
||||
else if (wasHidden) {
|
||||
imageryError = null;
|
||||
if (!imagery && !shell) installImagery();
|
||||
}
|
||||
if (wasHidden !== (imageryError === NO_IMAGERY_HOST)) onStatusChange?.();
|
||||
}
|
||||
|
||||
/** No idle animation for pause/reduced motion; scene events repaint only changed views. */
|
||||
function viewChanged() {
|
||||
if (gpuActive) {
|
||||
const viewer = viewerReady();
|
||||
if (viewer) updateImageryFade(viewer.scene.camera);
|
||||
const narrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
|
||||
let visibilityUpdated = false;
|
||||
if (running && field && !hidden() && narrow !== gpuNarrow) {
|
||||
gpuNarrow = narrow;
|
||||
gpuActive = gpu.setField(field);
|
||||
if (!gpuActive && viewer) {
|
||||
resize(viewer);
|
||||
seed(viewer.scene, makeOccluder(viewer.scene));
|
||||
}
|
||||
motionChanged();
|
||||
visibilityUpdated = true;
|
||||
}
|
||||
if (gpuActive && viewer && !visibilityUpdated) {
|
||||
gpuVisible = gpu.updateVisibility(viewer.scene.camera);
|
||||
if (gpuVisible) schedule();
|
||||
else {
|
||||
cancel();
|
||||
lastTime = null;
|
||||
}
|
||||
}
|
||||
const ready = gpuActive ? gpu.getDiagnostics().ready : null;
|
||||
if (ready !== reportedGpuReady) {
|
||||
reportedGpuReady = ready;
|
||||
onStatusChange?.();
|
||||
}
|
||||
return; // Cesium projects persistent curves; only a responsive budget change rebuilds them.
|
||||
}
|
||||
if (!running || !field || hidden() || !canvas) return;
|
||||
const viewer = viewerReady();
|
||||
if (!viewer) return;
|
||||
const resized = resize(viewer);
|
||||
if (resized || checkCamera(viewer.scene)) {
|
||||
cameraChanged = true;
|
||||
if (still()) paint(viewer, 0, true);
|
||||
else schedule();
|
||||
}
|
||||
}
|
||||
function motionChanged() {
|
||||
reducedMotion = media?.matches === true;
|
||||
cancel();
|
||||
lastTime = null;
|
||||
cameraChanged = true;
|
||||
gpu.setOptions({ overlay, paused: still() });
|
||||
if (!running || !field || hidden()) return;
|
||||
const viewer = viewerReady();
|
||||
if (gpuActive) {
|
||||
if (viewer) updateImageryFade(viewer.scene.camera);
|
||||
gpuVisible = viewer ? gpu.updateVisibility(viewer.scene.camera) : false;
|
||||
if (gpuVisible) {
|
||||
gpu.tick(flowTime);
|
||||
viewer.scene.requestRender();
|
||||
schedule();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (still() && viewer) paint(viewer, 0, true);
|
||||
else schedule();
|
||||
}
|
||||
function listen(target, event, callback) {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(event, callback);
|
||||
removers.push(() => target.removeEventListener(event, callback));
|
||||
}
|
||||
function cameraSettled() {
|
||||
if (gpuActive || imageryKind === 'tileset')
|
||||
updateImageryFade(viewerReady()?.scene?.camera, true);
|
||||
cameraMoved();
|
||||
}
|
||||
function cameraMoved() {
|
||||
viewChanged();
|
||||
// A parked request-render scene may never run preRender until we wake it.
|
||||
if (gpuActive && running && field && !hidden())
|
||||
viewerReady()?.scene?.requestRender?.();
|
||||
}
|
||||
function listenScene(event, callback) {
|
||||
if (!event?.addEventListener) return;
|
||||
const remove = event.addEventListener(callback);
|
||||
removers.push(
|
||||
typeof remove === 'function'
|
||||
? remove
|
||||
: () => event.removeEventListener(callback),
|
||||
);
|
||||
}
|
||||
function attachListeners() {
|
||||
if (removers.length) return;
|
||||
media = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
|
||||
reducedMotion = media?.matches === true;
|
||||
listen(media, 'change', motionChanged);
|
||||
listen(globalThis.document, 'visibilitychange', motionChanged);
|
||||
listen(globalThis, 'resize', cameraMoved);
|
||||
listen(eventTarget, 'gev:map-stack-changed', rehome);
|
||||
const viewer = viewerReady();
|
||||
const camera = viewer?.scene?.camera;
|
||||
listenScene(viewer?.scene?.preRender, viewChanged);
|
||||
listenScene(camera?.moveEnd, cameraSettled);
|
||||
}
|
||||
function detachListeners() {
|
||||
for (const remove of removers) remove();
|
||||
removers = [];
|
||||
media = null;
|
||||
}
|
||||
|
||||
function paint(viewer, dt, staticFrame, fadeSeconds = dt) {
|
||||
const scene = viewer.scene;
|
||||
const resized = resize(viewer);
|
||||
const changed = checkCamera(scene) || cameraChanged || resized;
|
||||
const occluder = makeOccluder(scene);
|
||||
if (changed) {
|
||||
clearPixels();
|
||||
updateBounds(scene);
|
||||
cameraChanged = false;
|
||||
}
|
||||
if (particles.length !== budget()) seed(scene, occluder);
|
||||
if (staticFrame) clearPixels();
|
||||
context.globalAlpha = 1;
|
||||
context.globalCompositeOperation = 'destination-out';
|
||||
context.fillStyle = `rgba(0,0,0,${trailEraseAlpha(fadeSeconds)})`;
|
||||
context.fillRect(0, 0, cssWidth, cssHeight);
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
context.lineCap = 'round';
|
||||
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
|
||||
const speedScale = Math.max(0.002, Math.min(4000, cameraHeight / 400));
|
||||
const advection = { speedScale };
|
||||
// Stroke length is a display sample of the local field, not frame travel.
|
||||
// At low FPS capped advection still moves the head by dt, but a readable
|
||||
// world-anchored tail survives without extra projections or screen stretching.
|
||||
const strokeSeconds = staticFrame ? 3 : 2;
|
||||
painted = 0;
|
||||
for (const particle of particles) {
|
||||
sampleWind(field, particle.lon, particle.lat, wind);
|
||||
if (!staticFrame) {
|
||||
advectParticle(particle, wind, dt, advection);
|
||||
particle.age += dt;
|
||||
}
|
||||
let point = project(scene, occluder, particle.lon, particle.lat, screenB);
|
||||
if (changed && !inView(point)) {
|
||||
// Refill newly exposed regions while retaining world anchors still in view.
|
||||
spawn(particle, scene, occluder);
|
||||
sampleWind(field, particle.lon, particle.lat, wind);
|
||||
point = project(scene, occluder, particle.lon, particle.lat, screenB);
|
||||
}
|
||||
if (particle.age > particle.life || !inView(point)) {
|
||||
spawn(particle, scene, occluder);
|
||||
continue;
|
||||
}
|
||||
before.lon = particle.lon;
|
||||
before.lat = particle.lat;
|
||||
advectParticle(before, wind, -strokeSeconds, advection);
|
||||
const previous = project(
|
||||
scene,
|
||||
occluder,
|
||||
before.lon,
|
||||
before.lat,
|
||||
screenA,
|
||||
);
|
||||
if (!inView(previous)) continue;
|
||||
const startX = previous.x;
|
||||
const startY = previous.y;
|
||||
const visibility = previous.visibility;
|
||||
const distance = Math.hypot(point.x - startX, point.y - startY);
|
||||
// Perspective/frustum jumps and antimeridian discontinuities never become strokes.
|
||||
if (
|
||||
Math.abs(before.lon - particle.lon) > 180 ||
|
||||
distance > Math.min(80, cssWidth * 0.08)
|
||||
)
|
||||
continue;
|
||||
const alpha =
|
||||
Math.min(visibility, point.visibility) *
|
||||
Math.min(1, particle.age / 0.8, (particle.life - particle.age) / 1.2);
|
||||
if (alpha <= 0) continue;
|
||||
const speed = Math.hypot(wind.u, wind.v);
|
||||
context.globalAlpha = alpha * 0.55;
|
||||
context.lineWidth = 1.0;
|
||||
context.strokeStyle =
|
||||
overlay === 'none' ? windTrailColor(speed) : '#9ddce4';
|
||||
context.beginPath();
|
||||
context.moveTo(startX, startY);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
if (distance > 0.6) {
|
||||
context.globalAlpha = alpha * 0.9;
|
||||
context.strokeStyle = '#d6f4f5';
|
||||
context.lineWidth = 1.35;
|
||||
context.beginPath();
|
||||
context.moveTo(
|
||||
startX + (point.x - startX) * 0.72,
|
||||
startY + (point.y - startY) * 0.72,
|
||||
);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.stroke();
|
||||
}
|
||||
if (staticFrame) {
|
||||
// Preserve direction at rest with a small arrowhead, without moving the field.
|
||||
if (distance > 2) {
|
||||
const ux = (point.x - startX) / distance;
|
||||
const uy = (point.y - startY) / distance;
|
||||
context.beginPath();
|
||||
context.moveTo(point.x - ux * 3 - uy * 2, point.y - uy * 3 + ux * 2);
|
||||
context.lineTo(point.x, point.y);
|
||||
context.lineTo(point.x - ux * 3 + uy * 2, point.y - uy * 3 - ux * 2);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
painted++;
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function draw(time) {
|
||||
frame = null;
|
||||
if (!running || !field || hidden() || still()) return;
|
||||
if (gpuActive && !gpuVisible) {
|
||||
lastTime = null;
|
||||
return;
|
||||
}
|
||||
const viewer = viewerReady();
|
||||
if (!viewer) return;
|
||||
try {
|
||||
const resized = !gpuActive && resize(viewer);
|
||||
if (resized) cameraChanged = true;
|
||||
const elapsed =
|
||||
lastTime === null ? 1 / 60 : Math.max(0, (time - lastTime) / 1000);
|
||||
// Limit full particle paints to 30 Hz while preserving elapsed-time motion/fading.
|
||||
if (lastTime === null || elapsed >= 0.032 || cameraChanged) {
|
||||
if (gpuActive) {
|
||||
flowTime += elapsed;
|
||||
gpu.tick(flowTime);
|
||||
viewer.scene.requestRender();
|
||||
cameraChanged = false;
|
||||
} else paint(viewer, Math.min(0.1, elapsed), false, elapsed);
|
||||
lastTime = time;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!warned) {
|
||||
console.warn('[Data:Wind] frame failed:', error?.message || error);
|
||||
warned = true;
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
return {
|
||||
rehome,
|
||||
attach() {
|
||||
if (canvas) return;
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.dataset.gevWind = '1';
|
||||
Object.assign(canvas.style, {
|
||||
position: 'absolute',
|
||||
inset: '0',
|
||||
pointerEvents: 'none',
|
||||
zIndex: '1',
|
||||
});
|
||||
canvas.setAttribute?.('aria-hidden', 'true');
|
||||
context = canvas.getContext('2d');
|
||||
container.appendChild(canvas);
|
||||
},
|
||||
setField(next) {
|
||||
const nextField = next?.grid
|
||||
? { ...next.grid, u: next.u, v: next.v }
|
||||
: next;
|
||||
const viewer = viewerReady();
|
||||
const nextNarrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
|
||||
const reuseGeometry =
|
||||
gpuActive &&
|
||||
gpuNarrow === nextNarrow &&
|
||||
gpu.supported() &&
|
||||
sameWind(snapshot, next, field, nextField);
|
||||
snapshot = next;
|
||||
field = nextField;
|
||||
cameraChanged = true;
|
||||
lastTime = null;
|
||||
cancel();
|
||||
if (canvas && field) {
|
||||
gpuNarrow = nextNarrow;
|
||||
if (!reuseGeometry) {
|
||||
gpuActive = gpu.supported() && gpu.setField(field);
|
||||
reportedGpuReady = gpuActive ? gpu.getDiagnostics().ready : null;
|
||||
flowTime = 0;
|
||||
}
|
||||
clearPixels();
|
||||
particles = [];
|
||||
if (viewer && !gpuActive) {
|
||||
resize(viewer);
|
||||
seed(viewer.scene, makeOccluder(viewer.scene));
|
||||
}
|
||||
installImagery();
|
||||
relief.attach();
|
||||
motionChanged();
|
||||
}
|
||||
},
|
||||
setOptions(options = {}) {
|
||||
const nextOverlay = ['none', 'speed', 'temperature', 'pressure'].includes(
|
||||
options.overlay,
|
||||
)
|
||||
? options.overlay
|
||||
: overlay;
|
||||
const newPaused =
|
||||
typeof options.paused === 'boolean' ? options.paused : paused;
|
||||
const changedOverlay = nextOverlay !== overlay;
|
||||
const changedPause = newPaused !== paused;
|
||||
overlay = nextOverlay;
|
||||
paused = newPaused;
|
||||
if (changedOverlay) {
|
||||
installImagery();
|
||||
clearPixels();
|
||||
}
|
||||
if (changedOverlay || changedPause) motionChanged();
|
||||
},
|
||||
start() {
|
||||
if (running || !canvas) return;
|
||||
running = true;
|
||||
attachListeners();
|
||||
rehome();
|
||||
motionChanged();
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
cancel();
|
||||
lastTime = null;
|
||||
detachListeners();
|
||||
gpu.setOptions({ overlay, paused: true });
|
||||
},
|
||||
clear() {
|
||||
cancel();
|
||||
snapshot = null;
|
||||
field = null;
|
||||
particles = [];
|
||||
painted = 0;
|
||||
gpu.clear();
|
||||
relief.clear();
|
||||
gpuActive = false;
|
||||
gpuVisible = false;
|
||||
reportedGpuReady = null;
|
||||
flowTime = 0;
|
||||
removeImagery();
|
||||
imageryError = null;
|
||||
clearPixels();
|
||||
},
|
||||
destroy() {
|
||||
this.stop();
|
||||
this.clear();
|
||||
gpu.destroy();
|
||||
relief.destroy();
|
||||
canvas?.remove();
|
||||
canvas = null;
|
||||
context = null;
|
||||
},
|
||||
getParticleCount() {
|
||||
return gpuActive ? gpu.getParticleCount() : particles.length;
|
||||
},
|
||||
getDiagnostics() {
|
||||
return {
|
||||
overlay,
|
||||
paused,
|
||||
reducedMotion,
|
||||
hidden: hidden(),
|
||||
particleCount: gpuActive ? gpu.getParticleCount() : particles.length,
|
||||
renderMode: gpuActive ? 'gpu-streamlines' : 'canvas-fallback',
|
||||
gpu: gpu.getDiagnostics(),
|
||||
relief: relief.getDiagnostics(),
|
||||
painted,
|
||||
framePending: frame !== null,
|
||||
imageryActive: imagery !== null || shell !== null,
|
||||
host: shell ? 'shell' : imagery ? 'globe' : null,
|
||||
shell: shell?.getDiagnostics() ?? null,
|
||||
imageryError,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,105 @@
|
||||
import { readWindBody } from '../../sources/windBody.js';
|
||||
|
||||
/** Create a bounded, cancellable source for the same-origin wind provider. */
|
||||
export function createWindSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
timeoutMs = 45_000,
|
||||
} = {}) {
|
||||
return {
|
||||
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
|
||||
if (!['gfs', 'ifs'].includes(model))
|
||||
throw new Error('Unknown wind model');
|
||||
if (!['none', 'temperature', 'pressure'].includes(overlay))
|
||||
throw new Error('Unknown weather overlay');
|
||||
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Wind request timed out')),
|
||||
timeoutMs,
|
||||
);
|
||||
const active = controller.signal;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/api/wind/manifest?${query}`, {
|
||||
signal: active,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
|
||||
const manifest = JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
await readWindBody(response, 16_384, active),
|
||||
),
|
||||
);
|
||||
if (manifest?.unavailable) return manifest;
|
||||
const grid = manifest?.grid;
|
||||
const scalar = manifest?.scalar;
|
||||
const scalarError = manifest?.scalarError;
|
||||
const optionalUnavailable =
|
||||
overlay !== 'none' &&
|
||||
scalar === undefined &&
|
||||
scalarError ===
|
||||
(overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable');
|
||||
const scalarValid =
|
||||
overlay === 'none'
|
||||
? scalar === undefined && scalarError === undefined
|
||||
: optionalUnavailable ||
|
||||
(scalarError === undefined &&
|
||||
scalar?.kind === overlay &&
|
||||
scalar.units === (overlay === 'temperature' ? '°C' : 'hPa') &&
|
||||
scalar.level ===
|
||||
(overlay === 'temperature'
|
||||
? '2 m above ground'
|
||||
: 'mean sea level'));
|
||||
if (
|
||||
manifest?.model !== model ||
|
||||
(manifest.overlay ?? 'none') !== overlay ||
|
||||
!scalarValid ||
|
||||
!grid ||
|
||||
!Number.isInteger(grid.nx) ||
|
||||
!Number.isInteger(grid.ny) ||
|
||||
grid.nx < 1 ||
|
||||
grid.ny < 1 ||
|
||||
grid.nx * grid.ny > 1_000_000 ||
|
||||
![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) ||
|
||||
grid.dx <= 0 ||
|
||||
grid.dy <= 0 ||
|
||||
Math.abs(grid.nx * grid.dx - 360) > 0.01 ||
|
||||
!new RegExp(
|
||||
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
|
||||
).test(manifest.gridUrl)
|
||||
)
|
||||
throw new Error('Malformed wind manifest');
|
||||
const count = grid.nx * grid.ny;
|
||||
const expectedBytes = count * (scalar ? 12 : 8);
|
||||
const gridResponse = await fetchImpl(manifest.gridUrl, {
|
||||
signal: active,
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!gridResponse.ok)
|
||||
throw new Error(`Wind HTTP ${gridResponse.status}`);
|
||||
const bytes = await readWindBody(gridResponse, expectedBytes, active);
|
||||
if (bytes.byteLength !== expectedBytes)
|
||||
throw new Error('Malformed wind grid');
|
||||
const values = new Float32Array(bytes.buffer);
|
||||
if (!values.every(Number.isFinite))
|
||||
throw new Error('Malformed wind grid');
|
||||
return {
|
||||
...manifest,
|
||||
u: values.slice(0, count),
|
||||
v: values.slice(count, count * 2),
|
||||
...(scalar
|
||||
? { scalar: { ...scalar, values: values.slice(count * 2) } }
|
||||
: {}),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindSource } from './source.js';
|
||||
const manifest = {
|
||||
model: 'gfs',
|
||||
grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 },
|
||||
gridUrl: '/api/wind/grid/gfs-test.bin?model=gfs',
|
||||
};
|
||||
const response = (value) =>
|
||||
new Response(
|
||||
typeof value === 'object' && !(value instanceof ArrayBuffer)
|
||||
? JSON.stringify(value)
|
||||
: value,
|
||||
);
|
||||
test('wind source splits a bounded field and preserves unavailable responses', async () => {
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? manifest
|
||||
: Float32Array.from([1, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
const field = await source.getSnapshot();
|
||||
assert.deepEqual([...field.u], [1, 2]);
|
||||
assert.deepEqual([...field.v], [3, 4]);
|
||||
const unavailable = createWindSource({
|
||||
fetchImpl: async () => response({ unavailable: true }),
|
||||
});
|
||||
assert.equal((await unavailable.getSnapshot()).unavailable, true);
|
||||
});
|
||||
test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => {
|
||||
for (const bad of [
|
||||
{ ...manifest, gridUrl: 'https://example.com/exfil' },
|
||||
{ ...manifest, grid: { ...manifest.grid, nx: 1e9 } },
|
||||
]) {
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(bad);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed/);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? manifest
|
||||
: Float32Array.from([NaN, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /Malformed/);
|
||||
});
|
||||
test('wind source timeout aborts the underlying fetch', async () => {
|
||||
const source = createWindSource({
|
||||
timeoutMs: 5,
|
||||
fetchImpl: (_, { signal }) =>
|
||||
new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason), {
|
||||
once: true,
|
||||
}),
|
||||
),
|
||||
});
|
||||
await assert.rejects(source.getSnapshot(), /timed out/);
|
||||
});
|
||||
|
||||
test('wind source requests optional companions and splits exact U/V/scalar byte regions', async () => {
|
||||
for (const [overlay, units, level, expected] of [
|
||||
['temperature', '°C', '2 m above ground', [-5, 25]],
|
||||
['pressure', 'hPa', 'mean sea level', [995, 1025]],
|
||||
]) {
|
||||
const scalar = { kind: overlay, units, level };
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay,
|
||||
scalar,
|
||||
gridUrl: `/api/wind/grid/gfs-test-${overlay}.bin?model=gfs&overlay=${overlay}`,
|
||||
};
|
||||
const calls = [];
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
return response(
|
||||
url.includes('/manifest?')
|
||||
? variant
|
||||
: Float32Array.from([1, 2, 3, 4, ...expected]).buffer,
|
||||
);
|
||||
},
|
||||
});
|
||||
const field = await source.getSnapshot({ overlay });
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
`/api/wind/manifest?model=gfs&overlay=${overlay}`,
|
||||
);
|
||||
assert.equal(calls[1].url, variant.gridUrl);
|
||||
assert.ok(calls.every((call) => call.options.redirect === 'error'));
|
||||
assert.deepEqual([...field.u], [1, 2]);
|
||||
assert.deepEqual([...field.v], [3, 4]);
|
||||
assert.deepEqual([...field.scalar.values], expected);
|
||||
assert.equal(field.scalar.units, units);
|
||||
assert.equal(field.scalar.level, level);
|
||||
}
|
||||
});
|
||||
|
||||
test('wind source rejects scalar contract mismatches before fetching a grid', async () => {
|
||||
const base = {
|
||||
...manifest,
|
||||
overlay: 'temperature',
|
||||
scalar: { kind: 'temperature', units: '°C', level: '2 m above ground' },
|
||||
gridUrl:
|
||||
'/api/wind/grid/gfs-test-temperature.bin?model=gfs&overlay=temperature',
|
||||
};
|
||||
for (const bad of [
|
||||
{ ...base, overlay: 'pressure' },
|
||||
{ ...base, scalar: { ...base.scalar, units: 'K' } },
|
||||
{ ...base, scalar: { ...base.scalar, level: 'surface' } },
|
||||
{ ...base, scalar: undefined },
|
||||
{
|
||||
...base,
|
||||
gridUrl: base.gridUrl.replace('overlay=temperature', 'overlay=pressure'),
|
||||
},
|
||||
{ ...base, gridUrl: `https://example.com${base.gridUrl}` },
|
||||
]) {
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(bad);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ overlay: 'temperature' }),
|
||||
/Malformed/,
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
}
|
||||
let calls = 0;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response(base);
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getSnapshot({ overlay: 'humidity' }), /Unknown/);
|
||||
assert.equal(calls, 0);
|
||||
await assert.rejects(
|
||||
source.getSnapshot(),
|
||||
/Malformed/,
|
||||
'wind-only does not accept an unsolicited scalar',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind source bounds optional scalar length and rejects non-finite scalar values', async () => {
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay: 'pressure',
|
||||
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
|
||||
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
|
||||
};
|
||||
for (const values of [
|
||||
[1, 2, 3, 4],
|
||||
[1, 2, 3, 4, 1000, 1001, 1002],
|
||||
[1, 2, 3, 4, NaN, 1000],
|
||||
]) {
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest') ? variant : Float32Array.from(values).buffer,
|
||||
),
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getSnapshot({ overlay: 'pressure' }),
|
||||
/Malformed|budget/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelling an optional scalar stream cancels the active grid body', async () => {
|
||||
const variant = {
|
||||
...manifest,
|
||||
overlay: 'pressure',
|
||||
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
|
||||
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
|
||||
};
|
||||
let started;
|
||||
const ready = new Promise((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let cancelled = false;
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) => {
|
||||
if (url.includes('manifest')) return response(variant);
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start() {
|
||||
started();
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = source.getSnapshot({
|
||||
overlay: 'pressure',
|
||||
signal: controller.signal,
|
||||
});
|
||||
await ready;
|
||||
await Promise.resolve();
|
||||
controller.abort(new Error('overlay changed'));
|
||||
await assert.rejects(result, /overlay changed/);
|
||||
assert.equal(cancelled, true);
|
||||
});
|
||||
|
||||
test('wind source retains usable wind when the requested scalar is unavailable', async () => {
|
||||
for (const overlay of ['temperature', 'pressure']) {
|
||||
const partial = {
|
||||
...manifest,
|
||||
overlay,
|
||||
scalarError:
|
||||
overlay === 'temperature'
|
||||
? 'Temperature field unavailable'
|
||||
: 'Mean sea level pressure field unavailable',
|
||||
gridUrl: `/api/wind/grid/gfs-test-${overlay}-wind-only.bin?model=gfs&overlay=${overlay}`,
|
||||
};
|
||||
const source = createWindSource({
|
||||
fetchImpl: async (url) =>
|
||||
response(
|
||||
url.includes('manifest')
|
||||
? partial
|
||||
: Float32Array.from([1, 2, 3, 4]).buffer,
|
||||
),
|
||||
});
|
||||
const result = await source.getSnapshot({ overlay });
|
||||
assert.equal(result.scalar, undefined);
|
||||
assert.equal(result.scalarError, partial.scalarError);
|
||||
assert.deepEqual([...result.u], [1, 2]);
|
||||
assert.deepEqual([...result.v], [3, 4]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { metersPerDegreeLon, normalizeLongitude, sampleWind } from './model.js';
|
||||
|
||||
// Desktop density is bounded independently of viewport zoom; camera motion
|
||||
// never triggers a regional rebake. Narrow-screen cost stays at 1200 paths.
|
||||
export const WIND_PATH_LIMIT = 7200;
|
||||
export const WIND_NARROW_PATH_LIMIT = 1200;
|
||||
export const WIND_PATH_STEPS = 16;
|
||||
export const WIND_DISPLAY_HEIGHT_METERS = 12000;
|
||||
export const WIND_CELL_DEGREES = 30;
|
||||
const DEGREE_METERS = 111320;
|
||||
const GOLDEN_ANGLE = 137.50776405003785;
|
||||
|
||||
/** One bounded midpoint step. The integration interval is field time, not playback time. */
|
||||
function advance(field, point, seconds, scratch) {
|
||||
sampleWind(field, point[0], point[1], scratch);
|
||||
if (
|
||||
!Number.isFinite(scratch.u + scratch.v) ||
|
||||
Math.hypot(scratch.u, scratch.v) < 0.15
|
||||
)
|
||||
return null;
|
||||
const lonRate = scratch.u / metersPerDegreeLon(point[1]);
|
||||
const latRate = scratch.v / DEGREE_METERS;
|
||||
// Bound each geographic segment, including fast winds near the poles.
|
||||
const dt =
|
||||
Math.sign(seconds) *
|
||||
Math.min(
|
||||
Math.abs(seconds),
|
||||
0.75 / Math.max(Math.abs(lonRate), Math.abs(latRate), 1e-12),
|
||||
);
|
||||
const midLat = point[1] + latRate * dt * 0.5;
|
||||
if (Math.abs(midLat) > 88.5) return null;
|
||||
sampleWind(field, point[0] + lonRate * dt * 0.5, midLat, scratch);
|
||||
if (!Number.isFinite(scratch.u + scratch.v)) return null;
|
||||
const lon = normalizeLongitude(
|
||||
point[0] + (scratch.u * dt) / metersPerDegreeLon(midLat),
|
||||
);
|
||||
const lat = point[1] + (scratch.v * dt) / DEGREE_METERS;
|
||||
if (Math.abs(lat) > 88.5 || Math.abs(lon - point[0]) > 180) return null;
|
||||
if (Math.abs(lon - point[0]) + Math.abs(lat - point[1]) < 1e-8) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake geographic streamlines once from an immutable forecast snapshot.
|
||||
* Equal-area seeds avoid polar overpopulation. Increasing path position always
|
||||
* follows the wind; paths stop at the map seam, poles, calm or missing samples.
|
||||
*/
|
||||
export function bakeWindStreamlines(
|
||||
field,
|
||||
{ count = WIND_PATH_LIMIT, steps = WIND_PATH_STEPS, stepSeconds = 1800 } = {},
|
||||
) {
|
||||
if (
|
||||
!field ||
|
||||
field.nx < 2 ||
|
||||
field.ny < 2 ||
|
||||
!(field.dx > 0) ||
|
||||
!(field.dy > 0) ||
|
||||
!field.u ||
|
||||
!field.v
|
||||
)
|
||||
return [];
|
||||
const budget = Math.min(
|
||||
WIND_PATH_LIMIT,
|
||||
Math.max(0, Math.floor(Number.isFinite(count) ? count : WIND_PATH_LIMIT)),
|
||||
);
|
||||
const halfSteps = Math.min(
|
||||
WIND_PATH_STEPS,
|
||||
Math.max(1, Math.floor(Number.isFinite(steps) ? steps : WIND_PATH_STEPS)),
|
||||
);
|
||||
const interval = Math.min(
|
||||
3600,
|
||||
Math.max(1, Number.isFinite(stepSeconds) ? stepSeconds : 1800),
|
||||
);
|
||||
const paths = [];
|
||||
const scratch = { u: 0, v: 0 };
|
||||
for (let seed = 0; seed < budget; seed++) {
|
||||
const lon = normalizeLongitude(seed * GOLDEN_ANGLE);
|
||||
const lat =
|
||||
(Math.asin(
|
||||
(((seed + 0.5) / budget) * 2 - 1) * Math.sin((88 * Math.PI) / 180),
|
||||
) *
|
||||
180) /
|
||||
Math.PI;
|
||||
const center = [lon, lat];
|
||||
sampleWind(field, lon, lat, scratch);
|
||||
const speed = Math.hypot(scratch.u, scratch.v);
|
||||
if (!Number.isFinite(speed) || speed < 0.15) continue;
|
||||
const backward = [];
|
||||
const forward = [];
|
||||
for (const [direction, points] of [
|
||||
[-1, backward],
|
||||
[1, forward],
|
||||
]) {
|
||||
let point = center;
|
||||
for (let step = 0; step < halfSteps; step++) {
|
||||
point = advance(field, point, direction * interval, scratch);
|
||||
if (!point) break;
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
const coordinates = [...backward.reverse(), center, ...forward];
|
||||
if (coordinates.length < 3) continue;
|
||||
paths.push({
|
||||
coordinates,
|
||||
speed,
|
||||
seed,
|
||||
phase: ((seed + 1) * 0.6180339887498949) % 1,
|
||||
});
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Group baked paths by their middle coordinate, preserving path and cell order. */
|
||||
export function groupWindPaths(paths, cellDegrees = WIND_CELL_DEGREES) {
|
||||
if (!Number.isFinite(cellDegrees) || cellDegrees <= 0 || cellDegrees > 180)
|
||||
throw new RangeError('Wind cell size must be between 0 and 180 degrees');
|
||||
const columns = Math.ceil(360 / cellDegrees);
|
||||
const rows = Math.ceil(180 / cellDegrees);
|
||||
const cells = new Map();
|
||||
for (const path of paths) {
|
||||
const [lon, lat] =
|
||||
path.coordinates[Math.floor(path.coordinates.length / 2)];
|
||||
const column = Math.floor((normalizeLongitude(lon) + 180) / cellDegrees);
|
||||
const row = Math.min(
|
||||
rows - 1,
|
||||
Math.max(0, Math.floor((lat + 90) / cellDegrees)),
|
||||
);
|
||||
const id = row * columns + column;
|
||||
let cell = cells.get(id);
|
||||
if (!cell) {
|
||||
cell = { id, paths: [] };
|
||||
cells.set(id, cell);
|
||||
}
|
||||
cell.paths.push(path);
|
||||
}
|
||||
return [...cells.values()];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bakeWindStreamlines,
|
||||
groupWindPaths,
|
||||
WIND_CELL_DEGREES,
|
||||
WIND_PATH_LIMIT,
|
||||
WIND_PATH_STEPS,
|
||||
} from './streamlines.js';
|
||||
|
||||
function field(u = 12, v = 0) {
|
||||
return {
|
||||
nx: 4,
|
||||
ny: 3,
|
||||
lo1: -180,
|
||||
la1: 90,
|
||||
dx: 90,
|
||||
dy: 90,
|
||||
u: new Float32Array(12).fill(u),
|
||||
v: new Float32Array(12).fill(v),
|
||||
};
|
||||
}
|
||||
|
||||
test('streamline bake is deterministic, equal-area and strictly bounded', () => {
|
||||
const first = bakeWindStreamlines(field(), { count: 24 });
|
||||
assert.deepEqual(first, bakeWindStreamlines(field(), { count: 24 }));
|
||||
assert.equal(first.length, 24);
|
||||
for (const path of first) {
|
||||
assert.ok(path.coordinates.length <= WIND_PATH_STEPS * 2 + 1);
|
||||
assert.ok(path.coordinates.length >= 3);
|
||||
for (let i = 1; i < path.coordinates.length; i++) {
|
||||
assert.ok(
|
||||
path.coordinates[i][0] > path.coordinates[i - 1][0],
|
||||
'eastward field has consistently eastward paths',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(path.coordinates[i][0] - path.coordinates[i - 1][0]) < 1,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(path.coordinates[i][1] - path.coordinates[i - 1][1]) < 1e-9,
|
||||
);
|
||||
}
|
||||
}
|
||||
const oversized = bakeWindStreamlines(field(), {
|
||||
count: 10000,
|
||||
steps: 10000,
|
||||
});
|
||||
assert.ok(oversized.length <= WIND_PATH_LIMIT);
|
||||
assert.ok(oversized.every((path) => path.coordinates.length <= 33));
|
||||
});
|
||||
|
||||
test('bake stops at poles, seams, missing and calm data without invalid geometry', () => {
|
||||
assert.deepEqual(bakeWindStreamlines(field(0, 0)), []);
|
||||
assert.deepEqual(bakeWindStreamlines(field(NaN, 1)), []);
|
||||
assert.deepEqual(bakeWindStreamlines(null), []);
|
||||
for (const path of bakeWindStreamlines(field(150, 80), { count: 90 })) {
|
||||
for (let i = 0; i < path.coordinates.length; i++) {
|
||||
const [lon, lat] = path.coordinates[i];
|
||||
assert.ok(Number.isFinite(lon + lat));
|
||||
assert.ok(Math.abs(lat) <= 88.5);
|
||||
assert.ok(lon >= -180 && lon < 180);
|
||||
if (i) assert.ok(Math.abs(lon - path.coordinates[i - 1][0]) < 180);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('midpoint integration bends paths with a changing northward component', () => {
|
||||
const snapshot = field(20, 0);
|
||||
snapshot.v = new Float32Array([
|
||||
-20, -10, 0, 10, -20, -10, 0, 10, -20, -10, 0, 10,
|
||||
]);
|
||||
const path = bakeWindStreamlines(snapshot, { count: 1 })[0];
|
||||
assert.equal(path.coordinates.length, 33);
|
||||
const middle = path.coordinates[16];
|
||||
assert.equal(middle[0], 0);
|
||||
assert.ok(path.coordinates[0][1] > middle[1]);
|
||||
assert.ok(path.coordinates.at(-1)[1] > middle[1]);
|
||||
});
|
||||
|
||||
test('regional grouping preserves every baked path and ordering within each cell', () => {
|
||||
const paths = bakeWindStreamlines(field());
|
||||
const before = structuredClone(paths);
|
||||
const groups = groupWindPaths(paths);
|
||||
assert.equal(WIND_CELL_DEGREES, 30);
|
||||
assert.equal(groups.length, 72);
|
||||
assert.deepEqual(paths, before, 'grouping leaves the bake untouched');
|
||||
const grouped = groups.flatMap((cell) => cell.paths);
|
||||
assert.equal(grouped.length, paths.length);
|
||||
assert.equal(new Set(grouped).size, paths.length);
|
||||
for (const cell of groups) {
|
||||
assert.deepEqual(
|
||||
cell.paths,
|
||||
paths.filter((path) => cell.paths.includes(path)),
|
||||
);
|
||||
}
|
||||
assert.deepEqual(groups, groupWindPaths(paths));
|
||||
assert.deepEqual(groupWindPaths([]), []);
|
||||
});
|
||||
|
||||
test('grouping uses the middle coordinate, wraps longitude and clamps polar cell edges', () => {
|
||||
const path = (lon, lat) => ({
|
||||
coordinates: [
|
||||
[-15, -15],
|
||||
[lon, lat],
|
||||
[150, 80],
|
||||
],
|
||||
});
|
||||
const a = path(-180, -90),
|
||||
b = path(180, -90),
|
||||
c = path(540, -90);
|
||||
const north = path(179.9, 90),
|
||||
boundary = path(-150, -60);
|
||||
const groups = groupWindPaths([a, north, boundary, b, c]);
|
||||
assert.deepEqual(groups, [
|
||||
{ id: 0, paths: [a, b, c] },
|
||||
{ id: 71, paths: [north] },
|
||||
{ id: 13, paths: [boundary] },
|
||||
]);
|
||||
assert.equal(groupWindPaths([a, north], 90).length, 2);
|
||||
for (const size of [0, -30, NaN, Infinity, 181])
|
||||
assert.throws(() => groupWindPaths([], size), RangeError);
|
||||
});
|
||||
Reference in new issue
Block a user