Merge main into pr-665

Resolve the CHANGELOG conflict by keeping both sides' entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Sameh KhamisandClaude Opus 5.5 committed 2026-09-24 22:36:35 -07:00
commit 5472e54f73
359 files changed
+68490 -1131

No files matched your search

+16
View File
@@ -1,4 +1,8 @@
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
formatAwarenessLabel,
formatAwarenessDistance,
@@ -105,6 +109,9 @@ export function createRendering({
const canvas = layerState.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(width, height)
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
@@ -182,6 +189,15 @@ export function createRendering({
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
const sonarFactor =
sonar?.at(
geometry.centerX + Math.cos(projection.angle) * markerRadius,
geometry.centerY + Math.sin(projection.angle) * markerRadius,
) ?? 1;
marker._arrow.style.opacity = String(sonarFactor);
marker._label.style.opacity = String(
sonar ? sonar.label(sonarFactor) : 1,
);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
const label = formatAwarenessLabel(item);
+1
View File
@@ -63,6 +63,7 @@ export function createCctvLayer({ services, source }) {
_setCctvOverlayHostForTest: parts.testing._setCctvOverlayHostForTest,
createCctvProjectionOverlayEntry:
parts.projection.createCctvProjectionOverlayEntry,
getActiveVideoElement: parts.projection.getActiveVideoElement,
setCctvCardPresentationOptions:
parts.cards.setCctvCardPresentationOptions,
surfaceRegimeKey: parts.ground.surfaceRegimeKey,
+3
View File
@@ -83,6 +83,9 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo:
parts.model.isVideoFeedType(camera.feedType) &&
record.projection?.mode !== 'image',
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+72 -19
View File
@@ -1,3 +1,4 @@
import { attachCctvVideo } from './videoPlayback.js';
import * as Cesium from 'cesium';
import {
CCTV_PROJECTION_OVERLAY_SOURCE_ID,
@@ -207,6 +208,7 @@ export function createProjection({
// only re-uploads the plane texture when there is genuinely new content.
canvasStamp: 1,
lastSwappedCanvasStamp: 0,
disposed: false,
};
parts.frames.paintProjectionPlaceholder(ctx, record.camera);
@@ -214,16 +216,49 @@ export function createProjection({
if (mode === 'video') {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.preload = 'auto';
video.src = parts.frames.mediaUrlFor(record.camera);
video.addEventListener('canplay', () => {
video.play().catch(() => {});
});
// Cesium sizes the video texture from the element's width/height
// attributes at first upload. Set them from the real stream dimensions
// and rebind on any resolution change (camera switch, adaptive source).
const bindVideoTexture = () => {
if (runtime.video !== video || !runtime.planeMaterial) return;
if (!(video.videoWidth > 0 && video.videoHeight > 0)) return;
video.width = video.videoWidth;
video.height = video.videoHeight;
runtime.planeMaterial.image = runtime.canvas;
runtime.planeMaterial.image = video;
};
video.addEventListener('loadedmetadata', bindVideoTexture);
video.addEventListener('resize', bindVideoTexture);
runtime.video = video;
runtime.playback = attachCctvVideo(
video,
parts.frames.mediaUrlFor(record.camera),
feedType,
{
onFailure: () => {
if (runtime.disposed) return;
runtime.video = null;
runtime.mode = 'image';
runtime.image = new Image();
runtime.image.decoding = 'async';
runtime.image.onload = () => {
runtime.imageLoading = false;
runtime.imageReady = true;
runtime.imageStamp = Date.now();
};
runtime.image.onerror = () => {
runtime.imageLoading = false;
runtime.imageReady = false;
};
runtime.planeMaterial.image = runtime.canvas;
parts.frames.refreshProjectionImage(record, true);
parts.presentation.notifyListeners();
},
},
);
} else {
const img = new Image();
img.decoding = 'async';
@@ -254,7 +289,7 @@ export function createProjection({
const positions =
record.frustumPositions || parts.geometry.frustumCartesians(geometry);
runtime.planeMaterial = new Cesium.ImageMaterialProperty({
image: mode === 'video' && runtime.video ? runtime.video : canvas,
image: canvas,
transparent: true,
color: Cesium.Color.WHITE.withAlpha(0.95),
});
@@ -271,7 +306,8 @@ export function createProjection({
function ensureProjectionRuntime(record) {
if (!record) return null;
if (record.projection) return record.projection;
if (record.projection && !record.projection.disposed)
return record.projection;
const runtime = createProjectionRuntime(record);
record.projection = runtime;
if (runtime) {
@@ -287,7 +323,15 @@ export function createProjection({
*/
function destroyProjectionRuntime(runtime) {
if (!runtime) return;
if (!runtime || runtime.disposed) return;
runtime.disposed = true;
runtime.playback?.dispose();
runtime.playback = null;
if (runtime.image) {
runtime.image.onload = null;
runtime.image.onerror = null;
runtime.image.src = '';
}
if (runtime.video) {
runtime.video.pause();
runtime.video.removeAttribute('src');
@@ -324,10 +368,7 @@ export function createProjection({
const active = parts.selection.getActiveRecord();
if (layerState._enabled && layerState._showProjection && active) {
ensureProjectionRuntime(active);
if (active.projection?.video) {
active.projection.video.play().catch(() => {});
}
if (active.projection) {
if (active.projection && !active.projection.video) {
parts.frames.drawProjectionFrame(active);
parts.frames.refreshProjectionTextures(active);
}
@@ -355,22 +396,34 @@ export function createProjection({
* @param {string|null} activeId - ID of the currently active camera.
*/
/**
* The active camera's decoded <video>, for a second surface (the panel card)
* to paint from. Null when the active feed is a still or not yet attached.
* @returns {HTMLVideoElement|null}
*/
function getActiveVideoElement() {
if (!layerState._enabled) return null;
return parts.selection.getActiveRecord()?.projection?.video || null;
}
function pauseInactiveProjectionFeeds(activeId) {
for (const record of layerState._records) {
if (!record.projection?.video) continue;
if (
record.camera.id === activeId &&
layerState._enabled &&
layerState._showProjection
) {
if (record.camera.id === activeId && layerState._enabled) {
record.projection.video.play().catch(() => {});
} else {
record.projection.video.pause();
const runtime = record.projection;
destroyProjectionRuntime(runtime);
record.projection = null;
layerState._projectionEntities = layerState._projectionEntities.filter(
(entry) => entry !== runtime,
);
}
}
}
return {
createCctvProjectionOverlayEntry,
getActiveVideoElement,
updatePlanePlacement,
clearProjectionOverlay,
setPlaneVisible,
+6 -1
View File
@@ -215,7 +215,12 @@ export function createRendering({
// (set at creation) — see the field-test far-zoom submerge fix there.
}
if (layerState._enabled && layerState._showProjection && isActive) {
if (
layerState._enabled &&
isActive &&
(layerState._showProjection ||
parts.model.isVideoFeedType(record.camera.feedType))
) {
parts.projection.ensureProjectionRuntime(record);
}
// One live plane in the world at a time (§2c): only the active camera's
+111
View File
@@ -0,0 +1,111 @@
/** One decoder per active camera; both surfaces consume this video element. */
export function attachCctvVideo(
video,
url,
feedType,
{
loadHls = () => import('hls.js'),
onFailure = () => {},
fetchImpl = globalThis.fetch,
} = {},
) {
// Preserve replay for existing finite video feeds; live HLS must not loop.
video.loop = feedType !== 'hls';
let disposed = false;
let hls = null;
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
const mediaUrl =
feedType === 'hls'
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
: url;
let timer = null;
let startup = null;
let governor = null;
let retries = 0;
const release = () => {
if (feedType === 'hls') {
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
() => {},
);
}
};
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
clearTimeout(startup);
clearInterval(governor);
hls?.destroy();
hls = null;
video.removeEventListener('canplay', play);
video.removeEventListener('error', fail);
video.pause();
video.removeAttribute('src');
video.load();
release();
};
const fail = () => {
if (disposed) return;
dispose();
onFailure();
};
const play = () => {
if (disposed) return;
clearTimeout(startup);
video.play().catch(() => {});
};
video.addEventListener('canplay', play);
video.addEventListener('error', fail);
startup = setTimeout(fail, 30000);
const ready = (async () => {
if (feedType !== 'hls') {
video.src = mediaUrl;
return;
}
try {
const { default: Hls } = await loadHls();
if (disposed) return;
if (!Hls.isSupported()) {
if (video.canPlayType('application/vnd.apple.mpegurl'))
video.src = mediaUrl;
else fail();
return;
}
hls = new Hls({
enableWorker: true,
maxBufferLength: 24,
maxMaxBufferLength: 30,
backBufferLength: 0,
maxBufferSize: 16 * 1024 * 1024,
liveSyncDurationCount: 3,
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (disposed || !data?.fatal) return;
if (++retries > 2) {
fail();
return;
}
clearTimeout(timer);
timer = setTimeout(() => {
if (disposed) return;
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
else hls.loadSource(mediaUrl);
}, 2000);
});
governor = setInterval(() => {
if (disposed || !video.buffered?.length) return;
const ahead =
video.buffered.end(video.buffered.length - 1) - video.currentTime;
// Some agency encoders publish less media time than wall time. Bound
// correction rather than repeatedly draining the live buffer at 1x.
video.playbackRate =
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
}, 1000);
hls.attachMedia(video);
hls.loadSource(mediaUrl);
} catch {
fail();
}
})();
return { dispose, ready };
}
+93
View File
@@ -0,0 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { attachCctvVideo } from './videoPlayback.js';
function video() {
const v = new EventTarget();
Object.assign(v, {
src: '',
pause() {},
load() {},
removeAttribute() {},
play: () => Promise.resolve(),
});
return v;
}
test('switch during lazy import never starts the stale decoder', async () => {
let resolve;
let constructed = 0;
class Hls {
constructor() {
constructed++;
}
static isSupported() {
return true;
}
}
const source = video();
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: () =>
new Promise((r) => {
resolve = r;
}),
});
playback.dispose();
resolve({ default: Hls });
await playback.ready;
assert.equal(constructed, 0);
assert.equal(source.src, '');
});
test('missing decoder uses honest failure fallback once', async () => {
let failures = 0;
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
loadHls: async () => {
throw new Error('unavailable');
},
onFailure: () => failures++,
});
await playback.ready;
playback.dispose();
assert.equal(failures, 1);
});
test('native HLS releases its client lease without response-header access', async () => {
const source = video();
source.canPlayType = () => 'probably';
const releases = [];
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
loadHls: async () => ({ default: { isSupported: () => false } }),
fetchImpl: async (url, init) => {
releases.push({ url, init });
},
});
await playback.ready;
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
const requested = source.src;
playback.dispose();
playback.dispose();
assert.equal(releases.length, 1);
assert.equal(releases[0].url, requested);
assert.equal(releases[0].init.method, 'DELETE');
});
test('finite video feeds retain looping while live HLS does not loop', async () => {
for (const feedType of ['mp4', 'webm', 'hls']) {
const source = video();
source.loop = feedType === 'hls';
source.canPlayType = () => 'probably';
let imports = 0;
const playback = attachCctvVideo(source, '/api/cctv/media/a', feedType, {
loadHls: async () => {
imports++;
return { default: { isSupported: () => false } };
},
fetchImpl: async () => {},
});
try {
await playback.ready;
assert.equal(source.loop, feedType !== 'hls');
assert.equal(imports, feedType === 'hls' ? 1 : 0);
} finally {
playback.dispose();
}
}
});
+447
View File
@@ -0,0 +1,447 @@
import * as Cesium from 'cesium';
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
registerPickOwner,
unregisterPickOwner,
} from '../../data/pickRegistry.js';
import {
createCycloneRendering,
coherentCycloneGeometry,
} from './rendering.js';
import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
} from './labels.js';
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
const COVERAGE =
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
const CLASSIFICATION_NAMES = Object.freeze({
PTC: 'Potential tropical cyclone',
HU: 'Hurricane',
TS: 'Tropical storm',
TD: 'Tropical depression',
SS: 'Subtropical storm',
SD: 'Subtropical depression',
});
const classificationName = (code) =>
Object.hasOwn(CLASSIFICATION_NAMES, code) ? CLASSIFICATION_NAMES[code] : code;
const number = (value, unit) =>
value === null ? 'Unavailable' : `${value} ${unit}`;
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
cesium = Cesium,
createRendering = createCycloneRendering,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let viewer = null,
rendering = null,
snapshot = null,
request = null,
listener = null,
selectedId = null,
selectionIntent = 'auto',
navigationGeneration = 0,
clickHandler = null,
removeClickCapture = null;
let enabled = false,
loading = false,
error = null,
destroyed = false,
runNavigation = null;
const notify = () => listener?.();
const selected = () =>
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
function select(id) {
if (selectedId !== id) ++navigationGeneration;
selectedId = id;
rendering?.setSelection(id);
}
// Photorealistic 3D Tiles pick as tileset content without an entity id;
// that is empty map, the same as no pick at all on the globe.
const isSurfacePick = (picked) =>
!picked ||
(picked.id === undefined &&
(picked.content !== undefined ||
(typeof cesium.Cesium3DTileset === 'function' &&
picked.primitive instanceof cesium.Cesium3DTileset)));
function installSelection() {
if (
clickHandler ||
!viewer?.scene?.canvas ||
typeof cesium.ScreenSpaceEventHandler !== 'function'
)
return;
const owner = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler = owner;
// Snapshot before sibling bubble listeners can rebuild the overlay hit
// rectangles. A vessel selection does that synchronously in the same click.
const canvas = viewer.scene.canvas;
let capturedHit = null;
const overlayHit = (x, y) => {
const hit = hitTestOverlay(x, y);
return { sourceId: hit?.sourceId, entryId: hit?.entryId };
};
const capture = (event) => {
capturedHit = null;
const point = event.changedTouches?.[0] || event;
if (!Number.isFinite(point.clientX) || !Number.isFinite(point.clientY))
return;
const rect = canvas.getBoundingClientRect();
const x = point.clientX - rect.left,
y = point.clientY - rect.top;
capturedHit = { x, y, ...overlayHit(x, y) };
};
const resetCapture = () => {
capturedHit = null;
};
const resetEvents = [
'pointerdown',
'mousedown',
'touchstart',
'pointercancel',
'touchcancel',
];
for (const type of resetEvents)
canvas.addEventListener?.(type, resetCapture, { capture: true });
const events = ['pointerup', 'mouseup', 'touchend'];
for (const type of events)
canvas.addEventListener?.(type, capture, { capture: true });
removeClickCapture = () => {
for (const type of events)
canvas.removeEventListener?.(type, capture, { capture: true });
for (const type of resetEvents)
canvas.removeEventListener?.(type, resetCapture, { capture: true });
capturedHit = null;
};
owner.setInputAction((click) => {
const nativeHit = capturedHit;
capturedHit = null;
// Ambient selection yields to draw tools and Director; it never claims
// the pointer, camera, or tracking state.
if (
!enabled ||
destroyed ||
clickHandler !== owner ||
!isPointerFree() ||
!click?.position
)
return;
// AIS cards paint above the globe on a pointer-events:none canvas. The
// vessel handler resolves this same topmost hit before cyclone geometry.
const captureMatches =
nativeHit &&
Math.abs(nativeHit.x - click.position.x) < 1 &&
Math.abs(nativeHit.y - click.position.y) < 1;
const hit = captureMatches
? nativeHit
: overlayHit(click.position.x, click.position.y);
if (hit.sourceId === VESSEL_OVERLAY_SOURCE_ID) return;
// Storm cards and lead-hour labels paint on the same canvas; a click on
// one selects its storm. An id from a superseded advisory changes nothing.
if (hit.sourceId === CYCLONE_OVERLAY_SOURCE_ID) {
const id = cycloneStormIdFromEntryId(hit.entryId);
if (id) layer.setParams({ stormId: id });
return;
}
const picked = viewer.scene.pick(click.position);
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
}, cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeSelection() {
const owner = clickHandler;
clickHandler = null;
removeClickCapture?.();
removeClickCapture = null;
if (owner && !owner.isDestroyed?.()) owner.destroy();
}
const layer = {
id: 'weather-cyclones',
name: 'Cyclone advisories',
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({ viewer, cesium, overlayHost });
},
attachShellServices(services) {
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
if (!destroyed && !enabled) {
enabled = true;
registerPickOwner(
'weather-cyclones',
(id) => enabled && rendering?.ownsPickId?.(id) === true,
);
installSelection();
}
},
disable() {
enabled = false;
unregisterPickOwner('weather-cyclones');
removeSelection();
request?.abort();
request = null;
loading = false;
error = null;
snapshot = null;
selectedId = null;
selectionIntent = 'auto';
++navigationGeneration;
rendering?.clear();
},
async update(_viewer, { signal } = {}) {
if (!enabled || destroyed) return false;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const next = await feed.getSnapshot({ signal: controller.signal });
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (next.unavailable) {
// An expired advisory must not remain presented as current hazard context.
rendering.clear();
snapshot = next;
selectedId = null;
++navigationGeneration;
error = next.reason || 'Cyclone advisories unavailable';
return true;
}
const applied = await rendering.setSnapshot(next, {
signal: controller.signal,
});
if (
!applied ||
!enabled ||
controller.signal.aborted ||
request !== controller
)
return false;
snapshot = next;
error = null;
if (!snapshot.storms.some((storm) => storm.id === selectedId))
select(
selectionIntent === 'cleared'
? null
: snapshot.storms[0]?.id || null,
);
else rendering.setSelection(selectedId);
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Cyclone advisories unavailable';
// Failed acquisition has no bounded last-good age guarantee at this layer.
rendering?.clear();
snapshot = null;
selectedId = null;
++navigationGeneration;
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = false;
notify();
}
}
},
setParams(params = {}) {
if (!enabled || destroyed) return;
if (params.clear === true || params.stormId === null) {
selectionIntent = 'cleared';
select(null);
notify();
} else if (
typeof params.stormId === 'string' &&
snapshot?.storms.some((storm) => storm.id === params.stormId)
) {
selectionIntent = 'user';
select(params.stormId);
notify();
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
const sphere = rendering.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
runNavigation(() => {
if (
!enabled ||
destroyed ||
generation !== navigationGeneration ||
selectedId !== storm.id
)
return;
return viewer.camera.flyToBoundingSphere(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
: 1.4,
});
});
}
}
if (params.advisory === true && storm?.advisoryUrl)
openLink(storm.advisoryUrl);
},
getRowControls() {
const storm = selected();
const empty =
snapshot && !snapshot.unavailable && snapshot.storms.length === 0;
const geometry =
storm &&
(coherentCycloneGeometry(storm)
? 'Track and cone match this advisory'
: storm.geometryStatus === 'pending'
? `Track/cone awaiting advisory ${storm.advisoryNumber}`
: 'Track/cone unavailable');
const status =
error ||
(snapshot?.stale
? 'Cached advisory · stale source'
: loading
? 'Loading advisories…'
: storm && !coherentCycloneGeometry(storm)
? geometry
: null);
const detail = storm
? `${storm.name} · ${classificationName(storm.classification)} · Advisory ${storm.advisoryNumber} · issued ${utc(storm.issuedAt)}`
: empty
? 'No active NHC/CPHC systems'
: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}`
: loading
? 'Loading advisories…'
: 'Advisories unavailable';
const controls = {
readout: true,
summary: {
label: 'Cyclones · NHC / CPHC',
coverage: 'Atlantic · E/C Pacific',
compact: snapshot?.storms.length
? `${snapshot.storms.length} active storm${snapshot.storms.length === 1 ? '' : 's'}${storm ? ` · ${storm.name} selected` : ''}`
: detail,
actions: storm?.advisoryUrl
? [
{
id: 'advisory',
label: 'Official advisory ↗',
href: storm.advisoryUrl,
},
]
: [],
settings: [],
lines: storm
? [
{
id: 'position',
text: `Position as of ${utc(storm.positionAt)}`,
muted: true,
},
{
id: 'intensity',
text: `Maximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}`,
},
{ id: 'geometry', text: geometry, muted: true },
]
: [],
detail,
status,
units: 'kt',
},
list: {
ariaLabel: 'Active NHC and CPHC cyclone advisories',
items: (snapshot?.storms || []).map((item, index) => ({
id: item.id,
ordinal: index + 1,
lead: item.basin,
text: `${item.name} · ${classificationName(item.classification)} · ${item.windKt === null ? 'Wind unavailable' : `${item.windKt} kt`}`,
active: item.id === selectedId,
params: { stormId: item.id, focus: true },
})),
},
chips: [],
legend: storm
? [
{ label: 'Advisory center / forecast track', color: '#7fe6ed' },
{ label: 'Center-track uncertainty cone', color: '#7fe6ed44' },
]
: [],
info: storm
? `${detail}\nPosition as of ${utc(storm.positionAt)}\nMaximum sustained wind: ${number(storm.windKt, 'kt')} · Pressure: ${number(storm.pressureHpa, 'hPa')}\n${geometry}${status && status !== geometry ? '\n' + status : ''}\n${snapshot.coverage}`
: `${detail}${status ? '\n' + status : ''}\n${snapshot?.coverage || COVERAGE}`,
infoTitle:
'Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.',
};
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
const storm = selected();
return {
count: snapshot?.storms.length || 0,
lastUpdate: storm
? Date.parse(storm.issuedAt)
: snapshot?.fetchedAt || null,
loading,
error,
stale: Boolean(snapshot?.stale),
source: 'NOAA NHC / CPHC',
advisoryAt: storm?.issuedAt || null,
empty: Boolean(
snapshot && !snapshot.unavailable && !snapshot.storms.length,
),
};
},
getDiagnostics() {
return {
...rendering?.getDiagnostics(),
enabled,
loading,
requestPending: !!request,
selectionActive: clickHandler !== null,
selectedId,
selectionIntent,
timerActive: false,
};
},
destroy() {
if (destroyed) return;
layer.disable();
destroyed = true;
rendering?.destroy();
rendering = null;
viewer = null;
listener = null;
runNavigation = null;
},
};
return layer;
}
+673
View File
@@ -0,0 +1,673 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import {
claimPointer,
releasePointer,
isPointerFree,
} from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
const time = '2026-09-16T03:00:00.000Z';
const storm = (id = 'ep152026') => ({
id,
name: id === 'ep152026' ? 'Fifteen-E' : 'Another system',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: time,
issuedAt: time,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
});
const snapshot = (storms = [storm()]) => ({
schemaVersion: 1,
storms,
unavailable: false,
stale: false,
fetchedAt: Date.parse(time),
coverage: 'Atlantic and eastern/central North Pacific',
reason: null,
});
const deferred = () => {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
};
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
const handlers = [],
pickedEntity = {},
foreignEntity = {};
let picked = { id: pickedEntity },
overlayHit = null,
overlayTests = 0,
picks = 0,
notices = 0;
const rendering = {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
ownsPickId: (id) => id === 'cyclone:ep162026:center',
getDiagnostics: () => ({}),
};
const cesium = {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor(canvas) {
assert.ok(canvas);
handlers.push(this);
this.destroyed = false;
}
setInputAction(callback, type) {
assert.equal(type, 'left');
this.click = callback;
}
destroy() {
this.destroyed = true;
}
isDestroyed() {
return this.destroyed;
}
},
};
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium,
createRendering: () => rendering,
hitTestOverlay: () => {
overlayTests++;
return overlayHit;
},
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const viewer = {
scene: {
canvas,
pick() {
picks++;
return picked;
},
},
trackedEntity: null,
camera: {
flyToBoundingSphere() {
assert.fail('Click must not move the camera');
},
},
};
layer.init(viewer);
layer.setRowControlsListener(() => notices++);
assert.equal(handlers.length, 0, 'disabled module installs no handler');
layer.enable();
layer.enable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
true,
'flight tracking recognizes the cyclone pick and leaves the camera alone',
);
assert.equal(isOwnedByOtherLayer('vessels', 'cyclone:ep162026:center'), true);
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:foreign'),
false,
'registration recognizes exact current IDs, not a prefix',
);
assert.equal(handlers.length, 1, 'enable is idempotent');
await layer.update();
const click = { position: { x: 10, y: 20 } };
const owner = claimPointer('director');
assert.ok(owner);
try {
handlers[0].click(click);
assert.equal(picks, 0, 'claimed pointer is checked before picking');
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
} finally {
releasePointer(owner);
}
picked = { id: foreignEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
picked = { primitive: {} };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
picked = { id: pickedEntity };
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
const beforeCard = picks;
handlers[0].click(click);
assert.equal(
picks,
beforeCard,
'foreground AIS card wins before scene picking',
);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A native capture runs before an earlier sibling handler clears hit rects.
const up = new Event('pointerup');
Object.assign(up, { clientX: 110, clientY: 220 });
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'captured AIS hit survives synchronous sibling overlay rebuild',
);
// A captured background hit remains background even if a sibling adds a card.
canvas.dispatchEvent(up);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
// A new gesture clears an unconsumed release snapshot.
canvas.dispatchEvent(up);
canvas.dispatchEvent(new Event('pointerdown'));
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click({ position: { x: 15, y: 25 } });
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
'a different click cannot reuse the captured card',
);
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
const before = notices;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.match(layer.getRowControls().summary.detail, /Another system/);
assert.equal(layer.getRowControls().list.items[1].active, true);
assert.ok(notices > before);
assert.equal(viewer.trackedEntity, null);
picked = undefined;
overlayHit = { sourceId: 'ais-live-vessels' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
picked = { id: pickedEntity };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
picked = { id: 'flight:abc', primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
"another layer's pick leaves the selection alone",
);
picked = { content: {}, primitive: {} };
handlers[0].click(click);
assert.equal(
layer.getDiagnostics().selectedId,
null,
'3D Tiles surface content is empty map',
);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(
isPointerFree(),
true,
'ambient selection never claims the pointer',
);
layer.disable();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[0].destroyed, true);
assert.equal(layer.getDiagnostics().selectionActive, false);
const afterDisable = picks;
const afterDisableOverlay = overlayTests;
canvas.dispatchEvent(up);
assert.equal(
overlayTests,
afterDisableOverlay,
'disable removes native capture listeners',
);
handlers[0].click(click);
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
layer.enable();
await layer.update();
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
handlers[0].click(click);
assert.equal(
picks,
afterDisable,
'superseded handler remains inert after re-enable',
);
assert.equal(handlers.length, 2);
layer.destroy();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[1].destroyed, true);
});
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
const handlers = [];
let picks = 0,
overlayHit = null,
hostArgument;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium: {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor() {
handlers.push(this);
}
setInputAction(callback) {
this.click = callback;
}
destroy() {}
},
},
overlayHost: 'host',
createRendering: ({ overlayHost }) => {
hostArgument = overlayHost;
return {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: () => null,
ownsPickId: () => false,
getDiagnostics: () => ({}),
};
},
hitTestOverlay: () => overlayHit,
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
layer.init({
scene: {
canvas,
pick() {
picks++;
return { content: {} };
},
},
});
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const click = { position: { x: 10, y: 20 } };
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A card from a superseded advisory is not empty map.
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// The native release snapshot carries the card identity too.
const up = new Event('pointerup');
Object.assign(up, { clientX: 10, clientY: 20 });
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(picks, 0, 'card clicks never fall through to the scene');
handlers[0].click(click);
assert.equal(picks, 1);
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
layer.destroy();
});
function harness(
feed = { getSnapshot: async () => snapshot() },
{ reducedMotion = false } = {},
) {
const applied = [],
navigation = [],
opened = [];
let cleared = 0,
destroyed = 0,
selection = null;
const rendering = {
async setSnapshot(value) {
applied.push(value);
return true;
},
setSelection(id) {
selection = id;
},
getFocusSphere: () => ({ radius: 500000 }),
clear() {
cleared++;
},
destroy() {
destroyed++;
},
getDiagnostics: () => ({
entities: applied.at(-1)?.storms.length || 0,
timerActive: false,
}),
};
const layer = createCyclonesLayer({
feed,
createRendering: () => rendering,
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
});
const viewer = {
camera: {
flyToBoundingSphere: (sphere, options) =>
navigation.push({ sphere, options }),
},
};
layer.init(viewer);
layer.attachShellServices({
runNavigation: (fn) => {
navigation.push('claimed');
return fn();
},
});
return {
layer,
applied,
navigation,
opened,
get cleared() {
return cleared;
},
get destroyed() {
return destroyed;
},
get selection() {
return selection;
},
};
}
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm('ep162026')]);
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
next = snapshot([storm(), storm('ep162026')]);
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep162026' });
await h.layer.update();
assert.equal(h.selection, 'ep162026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
assert.equal(h.navigation.length, 0);
next = snapshot([storm()]);
await h.layer.update();
assert.equal(h.selection, 'ep152026');
assert.equal(h.layer.getDiagnostics().selectionIntent, 'user');
for (const clear of [{ stormId: null }, { clear: true }]) {
h.layer.setParams({ stormId: 'ep152026' });
h.layer.setParams(clear);
assert.equal(h.selection, null);
assert.equal(h.layer.getDiagnostics().selectionIntent, 'cleared');
await h.layer.update();
assert.equal(h.selection, null);
assert.equal(h.layer.getRowControls().summary.detail, '1 active storm');
}
h.layer.disable();
assert.equal(h.layer.getDiagnostics().selectionIntent, 'auto');
h.layer.enable();
await h.layer.update();
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
});
test('clearing while a refreshed data source is staging cannot reselect a storm', async () => {
const staged = deferred();
const staging = deferred();
let calls = 0;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot() },
createRendering: () => ({
setSnapshot() {
if (++calls === 1) return true;
staging.resolve();
return staged.promise;
},
setSelection() {},
getDiagnostics: () => ({}),
clear() {},
destroy() {},
}),
});
layer.init({});
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const pending = layer.update();
await staging.promise;
layer.setParams({ clear: true });
staged.resolve(true);
await pending;
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
layer.destroy();
});
test('queued focus is revoked by selection changes, clears, refresh fallback and teardown', async () => {
for (const change of ['select', 'clear', 'fallback', 'disable', 'destroy']) {
let next = snapshot([storm(), storm('ep162026')]);
const h = harness({ getSnapshot: async () => next });
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
if (change === 'select') {
h.layer.setParams({ stormId: 'ep162026' });
h.layer.setParams({ stormId: 'ep152026' });
} else if (change === 'clear') h.layer.setParams({ stormId: null });
else if (change === 'fallback') {
next = snapshot([storm('ep162026')]);
await h.layer.update();
} else h.layer[change]();
callbacks.shift()();
assert.equal(h.navigation.length, 0, change);
h.layer.destroy();
}
});
test('only the latest queued focus runs and an unchanged selection survives refresh', async () => {
const h = harness();
const callbacks = [];
h.layer.attachShellServices({ runNavigation: (fn) => callbacks.push(fn) });
h.layer.enable();
await h.layer.update();
h.layer.setParams({ stormId: 'ep152026', focus: true });
h.layer.setParams({ stormId: 'ep152026', focus: true });
await h.layer.update();
callbacks[0]();
assert.equal(h.navigation.length, 0);
callbacks[1]();
assert.equal(h.navigation.length, 1);
assert.equal(h.navigation[0].options.duration, 1.4);
h.layer.destroy();
});
test('classification display expands known codes without altering the source or guessing unknown meanings', async () => {
for (const [code, label] of [
['PTC', 'Potential tropical cyclone'],
['HU', 'Hurricane'],
['TS', 'Tropical storm'],
['TD', 'Tropical depression'],
['SS', 'Subtropical storm'],
['SD', 'Subtropical depression'],
['EX', 'EX'],
['UNKNOWN', 'UNKNOWN'],
]) {
const record = { ...storm(), classification: code };
const h = harness({ getSnapshot: async () => snapshot([record]) });
try {
h.layer.enable();
await h.layer.update();
const controls = h.layer.getRowControls();
assert.ok(controls.summary.detail.includes(` · ${label} · `));
assert.ok(controls.list.items[0].text.includes(` · ${label} · `));
assert.equal(record.classification, code);
assert.equal(h.applied[0].storms[0].classification, code);
} finally {
h.layer.destroy();
}
}
});
test('advisory selection uses accessible row descriptors and shared camera handoff', async () => {
const h = harness(
{ getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
{ reducedMotion: true },
);
h.layer.enable();
assert.equal(await h.layer.update(), true);
const controls = h.layer.getRowControls();
assert.equal(controls.readout, true);
assert.deepEqual(controls.chips, []);
assert.equal(controls.summary.coverage, 'Atlantic · E/C Pacific');
assert.equal(controls.summary.actions[0].href, storm().advisoryUrl);
assert.equal(controls.summary.lines.length, 3);
assert.deepEqual(controls.summary.settings, []);
assert.deepEqual(controls.summary.actions, [
{ id: 'advisory', label: 'Official advisory ↗', href: storm().advisoryUrl },
]);
assert.match(controls.summary.compact, /2 active storms · .* selected/);
assert.equal(controls.summary.sections, undefined);
assert.match(controls.list.ariaLabel, /NHC/);
assert.equal(controls.list.items.length, 2);
assert.equal(controls.list.items[0].active, true);
assert.match(controls.info, /awaiting advisory 10/);
assert.equal(
controls.info.split('Track/cone awaiting advisory 10').length - 1,
1,
);
assert.equal(controls.summary.status, 'Track/cone awaiting advisory 10');
assert.match(controls.info, /09-16 03:00 UTC/);
assert.match(controls.infoTitle, /not storm size/);
assert.match(controls.infoTitle, /follows the surface/);
assert.equal(controls.list.items[1].params.focus, true);
assert.equal(
controls.chips.some((chip) => chip.id === 'focus'),
false,
);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.selection, 'ep162026');
assert.equal(h.navigation[0], 'claimed');
assert.equal(h.navigation[1].options.duration, 0);
h.layer.setParams(controls.list.items[1].params);
assert.equal(h.navigation.length, 4, 'selected row focuses again');
h.layer.setParams({ advisory: true });
assert.match(h.opened[0], /^https:\/\/www\.nhc\.noaa\.gov\/text\//);
h.layer.disable();
h.layer.setParams({ focus: true, advisory: true });
assert.equal(h.navigation.length, 4);
assert.equal(h.opened.length, 1);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.layer.getDiagnostics().timerActive, false);
h.layer.destroy();
assert.equal(h.destroyed, 1);
});
test('empty successful coverage, unavailable source and stale advisory are different states', async () => {
let next = snapshot([]);
const h = harness({ getSnapshot: async () => next });
h.layer.enable();
await h.layer.update();
assert.equal(h.layer.getStats().empty, true);
assert.match(h.layer.getRowControls().summary.detail, /No active NHC\/CPHC/);
next = { ...snapshot(), stale: true };
await h.layer.update();
assert.match(h.layer.getRowControls().summary.status, /stale/);
assert.match(
h.layer.getRowControls().info,
/Track\/cone awaiting advisory 10/,
);
assert.match(h.layer.getRowControls().info, /Cached advisory · stale source/);
next = {
...snapshot([]),
unavailable: true,
reason: 'Cyclone data unavailable',
};
await h.layer.update();
assert.equal(h.layer.getStats().empty, false);
assert.match(h.layer.getRowControls().summary.status, /unavailable/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
test('disabled and superseded sources cannot publish late advisories even if they ignore abort', async () => {
const first = deferred(),
second = deferred();
const signals = [];
let calls = 0;
const h = harness({
getSnapshot: ({ signal }) => {
signals.push(signal);
return (++calls === 1 ? first : second).promise;
},
});
h.layer.enable();
const old = h.layer.update(),
latest = h.layer.update();
assert.equal(signals[0].aborted, true);
second.resolve(snapshot());
assert.equal(await latest, true);
first.resolve(snapshot([storm('ep162026')]));
assert.equal(await old, false);
assert.equal(h.applied.length, 1);
assert.equal(h.selection, 'ep152026');
h.layer.destroy();
const delayed = deferred();
const disabled = harness({ getSnapshot: () => delayed.promise });
disabled.layer.enable();
const work = disabled.layer.update();
disabled.layer.disable();
delayed.resolve(snapshot());
assert.equal(await work, false);
assert.equal(disabled.applied.length, 0);
assert.equal(disabled.layer.getDiagnostics().requestPending, false);
disabled.layer.destroy();
});
test('external cancellation and acquisition failure cannot install or retain unlabeled advisory geometry', async () => {
let fail = false;
const h = harness({
getSnapshot: async () => {
if (fail) throw new Error('Network unavailable');
return snapshot();
},
});
h.layer.enable();
assert.equal(
await h.layer.update(undefined, { signal: AbortSignal.abort() }),
false,
);
assert.equal(h.applied.length, 0);
await h.layer.update();
fail = true;
await h.layer.update();
assert.equal(h.layer.getStats().count, 0);
assert.match(h.layer.getStats().error, /Network/);
assert.ok(h.cleared > 0);
h.layer.destroy();
});
+180
View File
@@ -0,0 +1,180 @@
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../../overlays/worldOverlay.js';
/**
* @module cyclones/labels
* @description Storm cards and lead-hour labels on the shared world-overlay
* host. The renderer supplies the anchors its points are drawn and
* horizon-culled at, plus the selection; the host owns projection, the same
* ellipsoid horizon test, collisions, fades and paint.
*/
export const CYCLONE_OVERLAY_SOURCE_ID = 'weather-cyclones';
/** Lead-hour labels show within 4,000 km of the camera. */
export const CYCLONE_LEAD_LABEL_MAX_DISTANCE_M = 4_000_000;
/** Ambient ceiling: every storm plus the selected storm's forecast points. */
export const CYCLONE_OVERLAY_COHORT_LIMIT = 64;
/** Advisory centre and forecast track; single source for points and cards. */
export const CYCLONE_ACCENT = '#7fe6ed';
/** Selected advisory centre. */
export const CYCLONE_SELECTED_ACCENT = '#ffe19a';
/** Point sizes the cards keep clear of. */
export const CYCLONE_MARKER_PX = Object.freeze({
storm: 9,
selected: 12,
forecast: 5,
});
const STORM_PRIORITY = 1000;
const LEAD_PRIORITY = 500;
const CYCLONE_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
});
/** Fire-card anchor clearance: the gap and leader start follow the marker. */
function markerClearance(markerPx) {
const gapPx = Math.max(12, markerPx + 8);
return {
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
verticalOnly: true,
viewportMargin: 4,
};
}
const SHARED_CARD = Object.freeze({
cardStyle: 'tactical',
collisionGroup: 'ambient-card',
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// A click on a card selects its storm.
interactive: true,
});
/**
* One card per storm: name, then the source classification code.
* @param {{id:string,name:string,classification?:string,windKt?:?number,position:object}} storm
* @param {boolean} selected
* @returns {object}
*/
export function cycloneStormEntry(storm, selected) {
return {
...SHARED_CARD,
...markerClearance(
selected ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm,
),
id: `storm:${storm.id}`,
position: storm.position,
variant: selected ? 'selected' : 'card',
title: storm.name,
details: storm.classification ? [storm.classification] : [],
accent: selected ? CYCLONE_SELECTED_ACCENT : CYCLONE_ACCENT,
selected,
protected: selected,
priority: selected
? Number.MAX_SAFE_INTEGER
: STORM_PRIORITY + (Number(storm.windKt) || 0),
maxDistance: Number.POSITIVE_INFINITY,
};
}
/**
* Lead-hour label at one forecast point of the selected storm. Earlier lead
* times win collisions.
* @param {string} stormId
* @param {{tauHours:number,position:object}} point
* @returns {object}
*/
export function cycloneLeadEntry(stormId, point) {
return {
...SHARED_CARD,
...markerClearance(CYCLONE_MARKER_PX.forecast),
id: `lead:${stormId}:${point.tauHours}`,
position: point.position,
variant: 'card',
title: `${point.tauHours} h`,
details: [],
accent: CYCLONE_ACCENT,
selected: false,
protected: false,
priority: LEAD_PRIORITY - point.tauHours,
maxDistance: CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
// Hard cutoff at the limit, without a fade.
distanceFadeStartRatio: 1,
};
}
/**
* Storm cards for every storm and lead-hour labels for the selected one.
* @param {Array<object>} storms Renderer anchors with `forecasts`.
* @param {?string} selectedId
* @returns {Array<object>}
*/
export function cycloneOverlayEntries(storms, selectedId) {
const entries = [];
for (const storm of storms) {
const selected = storm.id === selectedId;
entries.push(cycloneStormEntry(storm, selected));
if (!selected) continue;
for (const point of storm.forecasts || [])
entries.push(cycloneLeadEntry(storm.id, point));
}
return entries;
}
/** Storm id behind a published card or lead-hour label id, else null. */
export function cycloneStormIdFromEntryId(entryId) {
const match = /^(?:storm|lead):([^:]+)(?::|$)/.exec(String(entryId ?? ''));
return match ? match[1] : null;
}
/**
* Publish the renderer's committed anchors and selection to the overlay host.
* @param {{host?:{setEntries:Function,setVisible:Function,clearSource:Function}}} [options]
*/
export function createCycloneLabels({ host = CYCLONE_OVERLAY_HOST } = {}) {
let storms = [];
let selectedId = null;
let published = false;
function publish() {
const entries = cycloneOverlayEntries(storms, selectedId);
let ambient = 0;
for (const entry of entries) if (!entry.protected) ambient++;
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, true);
host.setEntries(CYCLONE_OVERLAY_SOURCE_ID, entries, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: Math.min(CYCLONE_OVERLAY_COHORT_LIMIT, ambient),
moving: false,
});
published = true;
}
return {
/** Replace every anchor after a snapshot commits. */
setSnapshot(nextStorms, nextSelectedId = selectedId) {
storms = nextStorms;
selectedId = nextSelectedId;
publish();
},
/** Republish only when the selection changes a published snapshot. */
setSelection(id) {
if (id === selectedId) return;
selectedId = id;
if (published) publish();
},
clear() {
storms = [];
selectedId = null;
if (!published) return;
published = false;
host.clearSource(CYCLONE_OVERLAY_SOURCE_ID);
host.setVisible(CYCLONE_OVERLAY_SOURCE_ID, false);
},
};
}
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
CYCLONE_LEAD_LABEL_MAX_DISTANCE_M,
CYCLONE_OVERLAY_COHORT_LIMIT,
CYCLONE_OVERLAY_SOURCE_ID,
createCycloneLabels,
cycloneOverlayEntries,
cycloneStormIdFromEntryId,
} from './labels.js';
import {
normalizeOverlayEntry,
paintLaneForOverlayEntry,
WORLD_OVERLAY_PAINT_LANES,
} from '../../overlays/worldOverlay.js';
import { distanceFade } from '../../overlays/worldOverlayDraw.js';
const anchor = (longitude, latitude) =>
Cesium.Cartesian3.fromDegrees(longitude, latitude, 0);
const storms = () => [
{
id: 'al062026',
name: 'Fay',
classification: 'TS',
windKt: 50,
position: anchor(-60, 20),
forecasts: [
{ tauHours: 12, position: anchor(-61, 21) },
{ tauHours: 24, position: anchor(-62, 22) },
],
},
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
windKt: null,
position: anchor(-125, 15),
forecasts: [{ tauHours: 12, position: anchor(-126, 16) }],
},
];
const byId = (entries) => new Map(entries.map((entry) => [entry.id, entry]));
test('storm cards join the fire and vessel card tier with fire-card clearance', () => {
const input = storms();
const entries = byId(cycloneOverlayEntries(input, 'al062026'));
assert.deepEqual(
[...entries.keys()],
[
'storm:al062026',
'lead:al062026:12',
'lead:al062026:24',
'storm:ep152026',
],
'lead-hour labels belong to the selected storm only',
);
const selected = entries.get('storm:al062026');
const ambient = entries.get('storm:ep152026');
for (const card of [selected, ambient]) {
assert.equal(card.cardStyle, 'tactical');
assert.equal(card.collisionGroup, 'ambient-card');
assert.equal(card.verticalOnly, true);
assert.equal(card.horizonCull, true);
assert.equal(card.edgeFade, 'keyhole');
assert.equal(card.interactive, true);
assert.equal(card.maxDistance, Number.POSITIVE_INFINITY);
}
assert.equal(selected.position, input[0].position, 'renderer anchor as-is');
assert.equal(selected.title, 'Fay');
assert.deepEqual(selected.details, ['TS']);
assert.equal(selected.variant, 'selected');
assert.equal(selected.selected, true);
assert.equal(selected.protected, true);
assert.equal(selected.accent, '#ffe19a');
// Fire policy: gap = max(12, marker + 8), leader starts gap - 6 out.
assert.equal(selected.gapPx, 20);
assert.equal(selected.leaderOffsetPx, 14);
assert.equal(ambient.title, 'Fifteen-E');
assert.deepEqual(ambient.details, ['PTC']);
assert.equal(ambient.variant, 'card');
assert.equal(ambient.protected, false);
assert.equal(ambient.accent, '#7fe6ed');
assert.equal(ambient.gapPx, 17);
assert.equal(ambient.leaderOffsetPx, 11);
const lead = entries.get('lead:al062026:24');
assert.equal(lead.position, input[0].forecasts[1].position);
assert.equal(lead.title, '24 h');
assert.deepEqual(lead.details, []);
assert.equal(lead.cardStyle, 'tactical');
assert.equal(lead.collisionGroup, 'ambient-card');
assert.equal(lead.protected, false);
assert.equal(lead.interactive, true);
assert.equal(lead.maxDistance, CYCLONE_LEAD_LABEL_MAX_DISTANCE_M);
assert.equal(lead.gapPx, 13);
assert.equal(lead.leaderOffsetPx, 7);
assert.ok(ambient.priority > entries.get('lead:al062026:12').priority);
assert.ok(entries.get('lead:al062026:12').priority > lead.priority);
const none = cycloneOverlayEntries(input, null);
assert.deepEqual(
none.map((entry) => [entry.id, entry.variant]),
[
['storm:al062026', 'card'],
['storm:ep152026', 'card'],
],
);
assert.ok(none[0].priority > none[1].priority, 'stronger storm ranks first');
});
test('entries satisfy the shared host contract and keep the 4,000 km lead-hour rule', () => {
const entries = cycloneOverlayEntries(storms(), 'al062026').map((entry) =>
normalizeOverlayEntry(CYCLONE_OVERLAY_SOURCE_ID, entry),
);
const lane = (entry) =>
WORLD_OVERLAY_PAINT_LANES[paintLaneForOverlayEntry(entry)];
const [selected, lead] = entries;
assert.equal(lane(selected), 'selected');
assert.equal(lane(lead), 'ambient-card');
assert.equal(lane(entries.at(-1)), 'ambient-card');
for (const entry of entries) {
assert.equal(entry.collisionGroup, 'ambient-card');
assert.equal(entry.cardStyle, 'tactical');
assert.equal(entry.horizonCull, true);
}
const range = {
minDistance: lead.minDistance,
maxDistance: lead.maxDistance,
fadeStartRatio: lead.distanceFadeStartRatio,
};
assert.equal(distanceFade(3_999_000, range), 1, 'full strength inside');
assert.equal(distanceFade(4_000_000, range), 0, 'hidden at the limit');
assert.equal(
distanceFade(40_000_000, {
minDistance: selected.minDistance,
maxDistance: selected.maxDistance,
fadeStartRatio: selected.distanceFadeStartRatio,
}),
1,
'storm cards have no distance limit',
);
});
test('publisher republishes on snapshots and selection changes, and hides on clear', () => {
const calls = [];
const host = {
setEntries: (...args) => calls.push(['entries', ...args]),
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
};
const labels = createCycloneLabels({ host });
labels.setSelection('al062026');
labels.clear();
assert.deepEqual(calls, [], 'nothing is published before a snapshot');
labels.setSnapshot(storms(), 'al062026');
assert.deepEqual(
calls.map(([kind, source]) => [kind, source]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID],
['entries', CYCLONE_OVERLAY_SOURCE_ID],
],
);
assert.equal(calls[0][2], true);
const [, , entries, options] = calls[1];
assert.equal(entries.length, 4);
assert.deepEqual(options, {
cohortLimit: CYCLONE_OVERLAY_COHORT_LIMIT,
collisionCapacity: 3,
moving: false,
});
calls.length = 0;
labels.setSelection('al062026');
assert.deepEqual(calls, [], 'an unchanged selection publishes nothing');
labels.setSelection('ep152026');
const published = calls.find(([kind]) => kind === 'entries')[2];
assert.deepEqual(
published.map((entry) => entry.id),
['storm:al062026', 'storm:ep152026', 'lead:ep152026:12'],
);
assert.equal(published[1].variant, 'selected');
calls.length = 0;
labels.clear();
assert.deepEqual(calls, [
['clear', CYCLONE_OVERLAY_SOURCE_ID],
['visible', CYCLONE_OVERLAY_SOURCE_ID, false],
]);
labels.clear();
labels.setSelection('al062026');
assert.equal(calls.length, 2, 'a cleared publisher stays quiet');
});
test('storm identity resolves from card and lead-hour ids only', () => {
assert.equal(cycloneStormIdFromEntryId('storm:al062026'), 'al062026');
assert.equal(cycloneStormIdFromEntryId('lead:al062026:24'), 'al062026');
for (const value of ['vessel:123', 'storm:', 'lead:', '', null, undefined])
assert.equal(cycloneStormIdFromEntryId(value), null, String(value));
});
+314
View File
@@ -0,0 +1,314 @@
import {
CYCLONE_ACCENT,
CYCLONE_MARKER_PX,
CYCLONE_SELECTED_ACCENT,
createCycloneLabels,
} from './labels.js';
/** Only geometry from the displayed status advisory is eligible for rendering. */
export function coherentCycloneGeometry(storm) {
return (
storm.geometryStatus === 'current' &&
storm.geometryAdvisoryNumber === storm.advisoryNumber
);
}
/**
* Static Cesium entities with owned horizon culling; no timers or clock.
* Storm names and lead hours are published to the shared overlay host at the
* same anchors the points use.
*/
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
const labels = createCycloneLabels({ host: overlayHost });
let source = null,
generation = 0,
selected = null,
destroyed = false;
let centers = new Map(),
spheres = new Map();
let entityStorms = new WeakMap();
let entityIds = new Set();
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
let horizonStorms = [],
removePreRender = null,
pointOccluder = null,
sphereOccluder = null;
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
const white = C.Color.WHITE;
const render = () => {
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
};
function cullHorizon() {
pointOccluder.cameraPosition = viewer.camera.positionWC;
sphereOccluder.cameraPosition = viewer.camera.positionWC;
let changed = false;
for (let i = 0; i < horizonStorms.length; i++) {
const storm = horizonStorms[i];
for (let j = 0; j < storm.points.length; j++) {
const { entity, position } = storm.points[j];
const show = pointOccluder.isPointVisible(position);
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
if (!storm.shapes.length) continue;
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
for (let j = 0; j < storm.shapes.length; j++) {
const entity = storm.shapes[j];
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
}
if (changed) render();
}
function syncHorizonListener() {
if (!horizonStorms.length) {
removePreRender?.();
removePreRender = null;
return;
}
if (removePreRender) return;
pointOccluder ||= new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
// conservatively culls extents, retaining partially visible tracks/cones.
sphereOccluder ||= new C.Occluder(
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
viewer.camera.positionWC,
);
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
}
function remove(value) {
if (!value) return;
if (!viewer.dataSources.isDestroyed?.())
viewer.dataSources.remove(value, true);
value.entities.removeAll();
}
function select(id) {
selected = id;
for (const [stormId, entity] of centers) {
entity.point.color = stormId === id ? gold : blue;
entity.point.pixelSize =
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
}
labels.setSelection(id);
render();
}
return {
async setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
if (destroyed) return false;
const owner = ++generation;
const next = new C.CustomDataSource('weather-cyclones');
const nextCenters = new Map(),
nextSpheres = new Map();
const nextEntityStorms = new WeakMap();
const nextEntityIds = new Set();
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
const nextHorizonStorms = [];
const nextLabels = [];
const position = ({ longitude, latitude }) =>
C.Cartesian3.fromDegrees(longitude, latitude, 0);
const coordinate = (pair) =>
position({ longitude: pair[0], latitude: pair[1] });
try {
for (const storm of snapshot.storms) {
const horizon = { points: [], shapes: [], sphere: null };
const addEntity = (options) => {
const entity = next.entities.add(options);
if (options.position)
horizon.points.push({ entity, position: options.position });
else horizon.shapes.push(entity);
nextEntityStorms.set(entity, storm.id);
nextEntityIds.add(entity.id);
return entity;
};
const center = position(storm.position),
extent = [center];
const entity = addEntity({
id: `cyclone:${storm.id}:center`,
name: storm.name,
position: center,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.storm,
color: blue,
outlineColor: C.Color.BLACK,
outlineWidth: 2,
},
});
nextCenters.set(storm.id, entity);
const label = {
id: storm.id,
name: storm.name,
classification: storm.classification,
windKt: storm.windKt,
position: center,
forecasts: [],
};
nextLabels.push(label);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: storm.track?.coordinates || [];
lines.forEach((line, index) => {
const positions = line.map(coordinate);
extent.push(...positions);
addEntity({
id: `cyclone:${storm.id}:track:${index}`,
polyline: {
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
positions,
width: 2.5,
material: blue,
arcType: C.ArcType.GEODESIC,
},
});
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: storm.cone?.coordinates || [];
polygons.forEach((rings, index) => {
// Each exterior retains its own interior holes. Native geographic
// tessellation handles the antimeridian; never flatten rings.
const exterior = rings[0].map(coordinate);
extent.push(...exterior);
const holes = rings
.slice(1)
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
addEntity({
id: `cyclone:${storm.id}:cone:${index}`,
polygon: {
hierarchy: new C.PolygonHierarchy(exterior, holes),
classificationType: C.ClassificationType.BOTH,
material: blue.withAlpha(0.16),
arcType: C.ArcType.GEODESIC,
},
});
[exterior, ...holes.map((hole) => hole.positions)].forEach(
(positions, ringIndex) => {
addEntity({
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
polyline: {
positions,
width: 1,
material: blue.withAlpha(0.55),
arcType: C.ArcType.GEODESIC,
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
},
});
},
);
nextCounts.cones++;
});
for (const [index, point] of storm.forecastPoints.entries()) {
if (point.tauHours === 0) continue;
const p = position(point.position);
extent.push(p);
addEntity({
id: `cyclone:${storm.id}:forecast:${index}`,
position: p,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.forecast,
color: white,
outlineColor: C.Color.BLACK,
outlineWidth: 1,
},
});
label.forecasts.push({ tauHours: point.tauHours, position: p });
nextCounts.forecastPoints++;
}
}
const sphere = C.BoundingSphere.fromPoints(extent);
// A status-only point still has a useful regional camera destination.
sphere.radius = Math.max(sphere.radius, 500_000);
nextSpheres.set(storm.id, sphere);
horizon.sphere = sphere;
nextHorizonStorms.push(horizon);
}
await viewer.dataSources.add(next);
if (destroyed || generation !== owner || signal?.aborted) {
remove(next);
return false;
}
remove(source);
source = next;
centers = nextCenters;
spheres = nextSpheres;
entityStorms = nextEntityStorms;
entityIds = nextEntityIds;
counts = nextCounts;
horizonStorms = nextHorizonStorms;
syncHorizonListener();
labels.setSnapshot(nextLabels, selected);
select(selected);
return true;
} catch (error) {
remove(next);
if (signal?.aborted || generation !== owner || destroyed) return false;
throw error;
}
},
setSelection: select,
ownsPickId(id) {
return source !== null && typeof id === 'string' && entityIds.has(id);
},
pickStorm(picked) {
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
// cannot establish ownership, especially after an advisory replacement.
const entity = picked?.id;
return source && entity && typeof entity === 'object'
? entityStorms.get(entity) || null
: null;
},
getFocusSphere(id) {
return spheres.get(id) || null;
},
clear() {
++generation;
horizonStorms = [];
syncHorizonListener();
pointOccluder = null;
sphereOccluder = null;
remove(source);
source = null;
labels.clear();
centers.clear();
spheres.clear();
entityStorms = new WeakMap();
entityIds.clear();
selected = null;
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
render();
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
},
getDiagnostics() {
return {
...counts,
dataSources: Number(!!source),
entities: source?.entities.values.length || 0,
selectedId: selected,
timerActive: false,
};
},
};
}
+669
View File
@@ -0,0 +1,669 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createCycloneRendering } from './rendering.js';
import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js';
/** Records what the renderer publishes to the shared overlay host. */
function overlayRecorder() {
const calls = [];
return {
calls,
host: {
setEntries: (source, entries, options) =>
calls.push({ kind: 'entries', source, entries, options }),
setVisible: (source, visible) =>
calls.push({ kind: 'visible', source, visible }),
clearSource: (source) => calls.push({ kind: 'clear', source }),
},
get publishes() {
return calls.filter(({ kind }) => kind === 'entries').length;
},
published() {
return calls.findLast(({ kind }) => kind === 'entries')?.entries || [];
},
entry(id) {
return this.published().find((entry) => entry.id === id) || null;
},
};
}
function harness({ deferred = false } = {}) {
const sources = [],
completions = [],
pointOccluders = [],
sphereOccluders = [];
const listeners = new Set();
const visibility = {
points: new Map(),
spheres: new Map(),
pointCalls: [],
sphereCalls: [],
writes: 0,
};
const color = (value) => ({
value,
withAlpha: (alpha) => ({ value, alpha }),
});
const cesium = {
Color: {
fromCssColorString: color,
WHITE: color('white'),
BLACK: color('black'),
},
Cartesian2: class {
constructor(x, y) {
this.x = x;
this.y = y;
}
},
Cartesian3: {
ZERO: { x: 0, y: 0, z: 0 },
fromDegrees: (lon, lat, height) => ({ lon, lat, height }),
},
Ellipsoid: { WGS84: { minimumRadius: 6356752 } },
EllipsoidalOccluder: class {
constructor(ellipsoid, cameraPosition) {
this.ellipsoid = ellipsoid;
this.cameraPosition = cameraPosition;
pointOccluders.push(this);
}
isPointVisible(position) {
visibility.pointCalls.push(position);
return visibility.points.get(position) ?? true;
}
},
Occluder: class {
constructor(sphere, cameraPosition) {
this.sphere = sphere;
this.cameraPosition = cameraPosition;
sphereOccluders.push(this);
}
isBoundingSphereVisible(sphere) {
visibility.sphereCalls.push(sphere);
return visibility.spheres.get(sphere) ?? true;
}
},
PolygonHierarchy: class {
constructor(positions, holes = []) {
this.positions = positions;
this.holes = holes;
}
},
BoundingSphere: class {
constructor(center, radius) {
this.center = center;
this.radius = radius;
}
static fromPoints(points) {
return { points, radius: 10 };
}
},
LabelStyle: { FILL_AND_OUTLINE: 1 },
HorizontalOrigin: { LEFT: 1 },
ArcType: { GEODESIC: 1 },
HeightReference: Cesium.HeightReference,
ClassificationType: Cesium.ClassificationType,
DistanceDisplayCondition: Cesium.DistanceDisplayCondition,
CustomDataSource: class {
constructor() {
const values = [];
this.entities = {
values,
add: (value) => {
let show = true;
Object.defineProperty(value, 'show', {
get: () => show,
set: (next) => {
show = next;
visibility.writes++;
},
});
values.push(value);
return value;
},
removeAll: () => {
values.length = 0;
},
};
}
},
};
let renders = 0;
const overlay = overlayRecorder();
const viewer = {
camera: { positionWC: { x: 6378487, y: 0, z: 0 } },
scene: {
requestRender: () => renders++,
preRender: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
dataSources: {
add(value) {
if (deferred)
return new Promise((resolve) =>
completions.push(() => {
sources.push(value);
resolve(value);
}),
);
sources.push(value);
return Promise.resolve(value);
},
remove(value) {
const i = sources.indexOf(value);
if (i >= 0) sources.splice(i, 1);
},
},
};
return {
rendering: createCycloneRendering({
viewer,
cesium,
overlayHost: overlay.host,
}),
overlay,
sources,
completions,
viewer,
cesium,
visibility,
pointOccluders,
sphereOccluders,
listeners,
frame() {
for (const listener of listeners) listener();
},
get renders() {
return renders;
},
};
}
const storm = () => ({
id: 'ep152026',
name: 'Fifteen-E',
advisoryNumber: '10',
geometryAdvisoryNumber: '10',
geometryStatus: 'current',
position: { longitude: 179, latitude: 15 },
forecastPoints: [
{ position: { longitude: -179, latitude: 16 }, tauHours: 12 },
],
track: {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
],
},
cone: {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
},
});
test('horizon culling updates only changed entities and keeps selection independent', async () => {
const h = harness();
await h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near' }],
});
const entities = h.sources[0].entities.values;
const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:'));
const near = entities.filter((e) => e.id.startsWith('cyclone:near:'));
const forecast = far.find((e) => e.id.endsWith('forecast:0'));
const sphere = h.rendering.getFocusSphere('ep152026');
for (const entity of far)
if (entity.position) h.visibility.points.set(entity.position, false);
h.visibility.spheres.set(sphere, false);
h.rendering.setSelection('ep152026');
const publishes = h.overlay.publishes;
const before = h.renders;
h.frame();
assert.ok(far.every((e) => e.show === false));
assert.ok(near.every((e) => e.show === true));
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
forecast.position,
'the host culls labels at the same anchors the points use',
);
assert.equal(h.overlay.publishes, publishes, 'culling never republishes');
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
assert.equal(h.visibility.pointCalls.length, 4);
assert.deepEqual(h.visibility.sphereCalls, [
sphere,
h.rendering.getFocusSphere('near'),
]);
h.frame();
assert.equal(h.renders, before + 1);
assert.equal(h.visibility.writes, far.length);
// A visible portion of the extent keeps all tracks/cones shown even when
// the centre and an individual forecast point remain beyond the horizon.
h.visibility.spheres.set(sphere, true);
h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 };
h.frame();
assert.ok(far.filter((e) => !e.position).every((e) => e.show));
assert.ok(far.filter((e) => e.position).every((e) => !e.show));
assert.equal(h.renders, before + 2);
assert.equal(h.pointOccluders.length, 1);
assert.equal(h.sphereOccluders.length, 1);
assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84);
assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC);
assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC);
h.rendering.setSelection('near');
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(
h.overlay.entry('lead:near:12').position,
near.find((e) => e.id.endsWith('forecast:0')).position,
);
h.visibility.points.set(forecast.position, true);
const beforeReveal = h.renders;
const revealPublishes = h.overlay.publishes;
h.frame();
assert.equal(forecast.show, true);
assert.equal(h.overlay.entry('lead:ep152026:12'), null);
assert.equal(h.overlay.publishes, revealPublishes);
assert.equal(far[0].show, false);
assert.equal(h.renders, beforeReveal + 1);
h.rendering.destroy();
});
test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => {
const h = harness();
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
const old = [...h.sources[0].entities.values];
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.frame();
assert.ok(
h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)),
);
await h.rendering.setSnapshot({ storms: [] });
assert.equal(h.listeners.size, 0);
await h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
assert.equal(h.listeners.size, 0);
const before = h.renders;
h.frame();
assert.equal(h.renders, before);
await h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 1);
h.rendering.destroy();
h.rendering.destroy();
assert.equal(h.listeners.size, 0);
assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false);
assert.equal(h.listeners.size, 0);
});
test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => {
for (const method of ['clear', 'destroy']) {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.listeners.size, 0);
h.rendering[method]();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.listeners.size, 0);
}
});
test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => {
for (const globeShow of [true, false]) {
const sources = [];
const viewer = {
camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) },
scene: {
globe: { show: globeShow },
preRender: new Cesium.Event(),
requestRender() {},
},
dataSources: {
async add(source) {
sources.push(source);
},
remove() {},
},
};
const overlay = overlayRecorder();
const rendering = createCycloneRendering({
viewer,
cesium: Cesium,
overlayHost: overlay.host,
});
const at = (id, longitude) => ({
...storm(),
id,
position: { longitude, latitude: 0 },
forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }],
track: {
type: 'LineString',
coordinates: [
[longitude, 0],
[longitude, 1],
],
},
cone: {
type: 'Polygon',
coordinates: [
[
[longitude, 0],
[longitude + 1, 0],
[longitude, 1],
[longitude, 0],
],
],
},
});
await rendering.setSnapshot({
storms: [at('near', 0), at('far', 180), at('limb', 5)],
});
rendering.setSelection('near');
viewer.scene.preRender.raiseEvent();
const entities = sources[0].entities.values;
for (const entity of entities.filter((e) => e.position)) {
assert.equal(
entity.point.heightReference.getValue(),
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity);
assert.equal(entity.label, undefined, 'no Cesium label graphics');
const published = overlay.entry(
entity.id.includes(':forecast:')
? `lead:${entity.id.split(':')[1]}:24`
: `storm:${entity.id.split(':')[1]}`,
);
if (entity.id.startsWith('cyclone:near:')) {
assert.ok(
Cesium.Cartesian3.equals(
published.position,
entity.position.getValue(),
),
);
assert.equal(published.horizonCull, true);
}
if (entity.id === 'cyclone:near:forecast:0')
assert.equal(published.maxDistance, 4_000_000);
}
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:near:'))
.every((e) => e.show),
);
assert.ok(
entities
.filter((e) => e.id.startsWith('cyclone:far:'))
.every((e) => !e.show),
);
const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:'));
assert.ok(limb.filter((e) => e.position).every((e) => !e.show));
assert.ok(limb.filter((e) => !e.position).every((e) => e.show));
rendering.destroy();
assert.equal(viewer.scene.preRender.numberOfListeners, 0);
}
});
test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const oldEntities = [...h.sources[0].entities.values];
assert.equal(oldEntities.length, 8);
for (const entity of oldEntities) {
assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id);
assert.equal(h.rendering.ownsPickId(entity.id), true);
assert.equal(
h.rendering.pickStorm({ id: { ...entity } }),
null,
'copied ID is not ownership',
);
assert.equal(
h.rendering.pickStorm({ id: entity.id }),
null,
'string prefix is not ownership',
);
}
assert.equal(h.rendering.pickStorm(undefined), null);
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false);
await h.rendering.setSnapshot({ storms: [storm()] });
for (const entity of oldEntities)
assert.equal(h.rendering.pickStorm({ id: entity }), null);
const current = h.sources[0].entities.values[0];
assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026');
h.rendering.clear();
assert.equal(h.rendering.pickStorm({ id: current }), null);
assert.equal(h.rendering.ownsPickId(current.id), false);
h.rendering.destroy();
});
test('registry IDs switch only when the next coherent data source commits', async () => {
const h = harness({ deferred: true });
const first = h.rendering.setSnapshot({ storms: [storm()] });
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
h.completions.shift()();
await first;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
const next = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'ep162026' }],
});
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
h.completions.shift()();
await next;
assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false);
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true);
h.rendering.destroy();
assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false);
});
test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => {
const h = harness();
await h.rendering.setSnapshot({ storms: [storm()] });
const entities = h.sources[0].entities.values;
const cones = entities.filter((e) => e.polygon);
assert.equal(cones.length, 2);
assert.equal(cones[0].polygon.hierarchy.holes.length, 1);
assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178);
for (const cone of cones) {
assert.equal(
cone.polygon.classificationType,
h.cesium.ClassificationType.BOTH,
);
assert.equal(cone.polygon.height, undefined);
assert.equal(cone.polygon.extrudedHeight, undefined);
assert.equal(cone.polygon.outline, undefined);
}
const outlines = entities.filter((e) => e.id.includes(':outline:'));
assert.equal(outlines.length, 3);
assert.deepEqual(
outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])),
storm().cone.coordinates.flat(),
);
for (const outline of outlines) {
assert.deepEqual(outline.polyline.material, {
value: '#7fe6ed',
alpha: 0.55,
});
}
for (const { polyline } of entities.filter((e) => e.polyline)) {
assert.equal(polyline.clampToGround, true);
assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH);
assert.ok(polyline.positions.every((p) => p.height === 0));
}
assert.equal(
entities.find((e) => e.polyline).polyline.positions[1].lon,
-179,
);
assert.deepEqual(h.rendering.getDiagnostics(), {
storms: 1,
tracks: 1,
cones: 2,
forecastPoints: 1,
dataSources: 1,
entities: 8,
selectedId: null,
timerActive: false,
});
h.rendering.setSelection('ep152026');
assert.equal(
h.overlay.entry('lead:ep152026:12').position,
entities.find((e) => e.id.endsWith('forecast:0')).position,
);
assert.ok(entities.every((e) => e.label === undefined));
assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000);
h.rendering.destroy();
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().entities, 0);
});
test('pending or mismatched advisory geometry never renders even when supplied', async () => {
for (const changed of [
{ geometryStatus: 'pending' },
{ geometryAdvisoryNumber: '9' },
]) {
const h = harness();
await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] });
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.rendering.getDiagnostics().tracks, 0);
h.rendering.destroy();
}
});
test('a data-source add settling after disable is removed, without a new owner', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
h.completions.shift()();
assert.equal(await pending, false);
assert.equal(h.sources.length, 0);
assert.equal(h.rendering.getDiagnostics().dataSources, 0);
});
test('superseded asynchronous additions cannot replace newer geometry', async () => {
const h = harness({ deferred: true });
const old = h.rendering.setSnapshot({ storms: [storm()] });
const latest = h.rendering.setSnapshot({ storms: [] });
h.completions[1]();
assert.equal(await latest, true);
h.completions[0]();
assert.equal(await old, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 0);
h.rendering.destroy();
});
test('aborting a pending add retains the prior complete source', async () => {
const h = harness({ deferred: true });
const initial = h.rendering.setSnapshot({ storms: [storm()] });
h.completions.shift()();
await initial;
const controller = new AbortController();
const update = h.rendering.setSnapshot(
{ storms: [] },
{ signal: controller.signal },
);
controller.abort();
h.completions.shift()();
assert.equal(await update, false);
assert.equal(h.sources.length, 1);
assert.equal(h.rendering.getDiagnostics().storms, 1);
h.rendering.destroy();
});
test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => {
const h = harness({ deferred: true });
const pending = h.rendering.setSnapshot({
storms: [storm(), { ...storm(), id: 'near', name: 'Near' }],
});
assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit');
h.completions.shift()();
assert.equal(await pending, true);
const entities = h.sources[0].entities.values;
const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`);
assert.deepEqual(
h.overlay.calls
.slice(0, 2)
.map(({ kind, source, visible }) => [kind, source, visible]),
[
['visible', CYCLONE_OVERLAY_SOURCE_ID, true],
['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined],
],
);
assert.deepEqual(
h.overlay.published().map((entry) => entry.id),
['storm:ep152026', 'storm:near'],
'no selection, no lead-hour labels',
);
assert.equal(h.overlay.entry('storm:near').title, 'Near');
assert.equal(h.overlay.entry('storm:near').position, center('near').position);
h.rendering.setSelection('near');
assert.equal(center('near').point.pixelSize, 12);
assert.equal(center('ep152026').point.pixelSize, 9);
assert.equal(h.overlay.entry('storm:near').variant, 'selected');
assert.equal(h.overlay.entry('storm:ep152026').variant, 'card');
assert.ok(h.overlay.entry('lead:near:12'));
const publishes = h.overlay.publishes;
h.rendering.setSelection('near');
assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet');
// A refresh republishes the new anchors with the retained selection.
const refresh = h.rendering.setSnapshot({
storms: [{ ...storm(), id: 'near', name: 'Near' }],
});
h.completions.shift()();
await refresh;
assert.deepEqual(
h.overlay.published().map((entry) => [entry.id, entry.variant]),
[
['storm:near', 'selected'],
['lead:near:12', 'card'],
],
);
assert.equal(
h.overlay.entry('storm:near').position,
h.sources[0].entities.values[0].position,
);
// A superseded addition never publishes, and clear hides the source.
const stale = h.rendering.setSnapshot({ storms: [storm()] });
h.rendering.clear();
const afterClear = h.overlay.calls.slice(-2);
assert.deepEqual(afterClear, [
{ kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID },
{ kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false },
]);
h.completions.shift()();
assert.equal(await stale, false);
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
h.rendering.destroy();
assert.equal(h.overlay.calls.at(-1), afterClear[1]);
});
+245
View File
@@ -0,0 +1,245 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
!value.trim() ||
value.length > max ||
/[\u0000-\u001f<>]/.test(value)
)
throw malformed();
return value;
};
const number = (value, low, high) => {
if (value === null) return null;
if (!Number.isFinite(value) || value < low || value > high) throw malformed();
return value;
};
const time = (value) => {
if (
typeof value !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) ||
!Number.isFinite(Date.parse(value)) ||
new Date(value).toISOString() !== value
)
throw malformed();
return value;
};
const advisory = (value) => {
if (typeof value !== 'string' || !/^(?:0|[1-9]\d{0,2})[A-Z]?$/.test(value))
throw malformed();
return value;
};
const position = (value) => {
if (!value || value.longitude === null || value.latitude === null)
throw malformed();
return {
longitude: number(value.longitude, -180, 180),
latitude: number(value.latitude, -90, 90),
};
};
function officialLink(value, outlook = false) {
if (value === null) return null;
const url = new URL(text(value, 256));
if (
url.origin !== 'https://www.nhc.noaa.gov' ||
url.username ||
url.password ||
url.hash
)
throw malformed();
if (
outlook
? !/^\/gtwo\.php\?basin=(?:atlc|epac|cpac)&fdays=7$/.test(
url.pathname + url.search,
)
: url.search || !/^\/text\/[A-Z0-9]+\.shtml$/.test(url.pathname)
)
throw malformed();
return url.href;
}
function geometry(value, kind, budget) {
if (value === null) return null;
const allowed =
kind === 'track'
? ['LineString', 'MultiLineString']
: ['Polygon', 'MultiPolygon'];
if (!allowed.includes(value?.type)) throw malformed();
const point = (pair) => {
if (
!Array.isArray(pair) ||
pair.length !== 2 ||
pair.some((v) => typeof v !== 'number')
)
throw malformed();
position({ longitude: pair[0], latitude: pair[1] });
if (++budget.coordinates > 25_000) throw malformed();
return [...pair];
};
const list = (items, read, min = 1, max = 128) => {
if (!Array.isArray(items) || items.length < min || items.length > max)
throw malformed();
return items.map(read);
};
const line = (pairs) => list(pairs, point, 2, 10_000);
const ring = (pairs) => {
const result = list(pairs, point, 4, 10_000);
if (result[0][0] !== result.at(-1)[0] || result[0][1] !== result.at(-1)[1])
throw malformed();
return result;
};
const polygon = (rings) => list(rings, ring);
const coordinates =
value.type === 'LineString'
? line(value.coordinates)
: value.type === 'MultiLineString'
? list(value.coordinates, line)
: value.type === 'Polygon'
? polygon(value.coordinates)
: list(value.coordinates, polygon);
return { type: value.type, coordinates };
}
/** Project only the bounded status/geometry contract; never ingest GeoJSON properties or URLs. */
export function validateCycloneSnapshot(value) {
if (
!value ||
value.schemaVersion !== 1 ||
typeof value.stale !== 'boolean' ||
typeof value.unavailable !== 'boolean' ||
!Array.isArray(value.storms) ||
value.storms.length > 32
)
throw malformed();
const seen = new Set(),
budget = { coordinates: 0, points: 0 };
const storms = value.storms.map((raw) => {
if (
!/^(?:al|ep|cp)\d{6}$/.test(raw?.id) ||
seen.has(raw.id) ||
!['current', 'pending', 'unavailable'].includes(raw.geometryStatus)
)
throw malformed();
seen.add(raw.id);
const advisoryNumber = advisory(raw.advisoryNumber);
const geometryAdvisoryNumber =
raw.geometryAdvisoryNumber === null
? null
: advisory(raw.geometryAdvisoryNumber);
if (
raw.geometryStatus === 'current' &&
geometryAdvisoryNumber !== advisoryNumber
)
throw malformed();
if (
!Array.isArray(raw.forecastPoints) ||
(budget.points += raw.forecastPoints.length) > 500
)
throw malformed();
const taus = new Set();
const forecastPoints = raw.forecastPoints.map((point) => {
const tauHours = number(point.tauHours, 0, 168);
if (tauHours === null || taus.has(tauHours)) throw malformed();
taus.add(tauHours);
if (++budget.coordinates > 25_000) throw malformed();
return {
position: position(point.position),
tauHours,
windKt: number(point.windKt, 0, 300),
gustKt: number(point.gustKt, 0, 350),
};
});
const track = geometry(raw.track, 'track', budget),
cone = geometry(raw.cone, 'cone', budget);
if (
raw.geometryStatus !== 'current' &&
(track || cone || forecastPoints.length)
)
throw malformed();
if (
raw.geometryStatus === 'current' &&
(!track || !cone || !forecastPoints.length)
)
throw malformed();
if (raw.basin !== raw.id.slice(0, 2).toUpperCase()) throw malformed();
return {
id: raw.id,
name: text(raw.name, 80),
classification: text(raw.classification, 16),
basin: raw.basin,
position: position(raw.position),
positionAt: time(raw.positionAt),
advisoryNumber,
issuedAt: time(raw.issuedAt),
windKt: number(raw.windKt, 0, 300),
pressureHpa: number(raw.pressureHpa, 800, 1100),
movement: {
directionDegrees: number(raw.movement?.directionDegrees, 0, 360),
speedKt: number(raw.movement?.speedKt, 0, 200),
},
advisoryUrl: officialLink(raw.advisoryUrl),
outlookUrl: officialLink(raw.outlookUrl, true),
geometryStatus: raw.geometryStatus,
geometryAdvisoryNumber,
forecastPoints,
track,
cone,
};
});
if (value.unavailable && storms.length) throw malformed();
return {
schemaVersion: 1,
source: text(value.source),
attribution: text(value.attribution, 240),
coverage: text(value.coverage, 240),
fetchedAt: number(value.fetchedAt, 0, Number.MAX_SAFE_INTEGER),
stale: value.stale,
unavailable: value.unavailable,
reason: value.reason === null ? null : text(value.reason, 240),
storms,
};
}
/** Lazy same-origin acquisition; source owns only its deadline and cancellation. */
export function createCycloneSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ signal } = {}) {
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl('/api/cyclones', {
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`Cyclone HTTP ${response.status}`);
}
const result = await readResponseJsonCapped(
response,
CYCLONE_RESPONSE_LIMIT,
controller.signal,
);
controller.signal.throwIfAborted();
return validateCycloneSnapshot(result);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createCycloneSource,
validateCycloneSnapshot,
CYCLONE_RESPONSE_LIMIT,
} from './source.js';
const issuedAt = '2026-09-16T03:00:00.000Z';
function fixture() {
return {
schemaVersion: 1,
source: 'NOAA NHC / CPHC',
attribution: 'NOAA/NWS',
coverage: 'Atlantic and eastern/central North Pacific',
fetchedAt: Date.parse(issuedAt),
stale: false,
unavailable: false,
reason: null,
storms: [
{
id: 'ep152026',
name: 'Fifteen-E',
classification: 'PTC',
basin: 'EP',
position: { longitude: -125.8, latitude: 15.5 },
positionAt: issuedAt,
issuedAt,
advisoryNumber: '10',
windKt: 25,
pressureHpa: 1006,
movement: { directionDegrees: null, speedKt: null },
advisoryUrl: 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
outlookUrl: 'https://www.nhc.noaa.gov/gtwo.php?basin=epac&fdays=7',
geometryStatus: 'pending',
geometryAdvisoryNumber: null,
forecastPoints: [],
track: null,
cone: null,
},
],
};
}
function current() {
const value = fixture(),
storm = value.storms[0];
storm.geometryStatus = 'current';
storm.geometryAdvisoryNumber = '10';
storm.forecastPoints = [
{
position: { longitude: 179, latitude: 15 },
tauHours: 12,
windKt: 30,
gustKt: 40,
},
];
storm.track = {
type: 'MultiLineString',
coordinates: [
[
[179, 15],
[-179, 16],
],
[
[170, 14],
[179, 15],
],
],
};
storm.cone = {
type: 'MultiPolygon',
coordinates: [
[
[
[178, 10],
[-178, 10],
[-178, 20],
[178, 10],
],
[
[179, 12],
[-179, 12],
[-179, 14],
[179, 12],
],
],
[
[
[170, 1],
[171, 1],
[171, 2],
[170, 1],
],
],
],
};
return value;
}
test('cyclone projection preserves MultiPolygon holes/dateline and separates advisory from position time', () => {
const value = current();
value.storms[0].positionAt = '2026-09-16T03:30:00.000Z';
value.storms[0].cone.properties = {
html: '<script>',
href: 'https://example.invalid',
};
const result = validateCycloneSnapshot(value);
assert.deepEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
assert.notEqual(result.storms[0].positionAt, result.storms[0].issuedAt);
assert.equal(result.storms[0].cone.properties, undefined);
assert.notEqual(
result.storms[0].cone.coordinates,
value.storms[0].cone.coordinates,
);
});
test('successful empty coverage is distinct from unavailable', () => {
const value = fixture();
value.storms = [];
assert.equal(validateCycloneSnapshot(value).unavailable, false);
assert.equal(
validateCycloneSnapshot({
...value,
unavailable: true,
stale: true,
fetchedAt: null,
reason: 'Unavailable',
}).unavailable,
true,
);
assert.throws(
() => validateCycloneSnapshot({ ...fixture(), unavailable: true }),
/Malformed/,
);
});
test('mismatched, malformed or unbounded cyclone geometry is rejected whole', () => {
const changes = [
(storm) => {
storm.geometryAdvisoryNumber = '9';
},
(storm) => {
storm.geometryStatus = 'pending';
},
(storm) => {
storm.position.longitude = 181;
},
(storm) => {
storm.track.coordinates[0][0][0] = NaN;
},
(storm) => {
storm.cone.coordinates[0][0].pop();
},
(storm) => {
storm.forecastPoints.push(storm.forecastPoints[0]);
},
(storm) => {
storm.advisoryUrl = 'https://example.invalid/text/TEST.shtml';
},
(storm) => {
storm.issuedAt = '2026-02-31T00:00:00.000Z';
},
(storm) => {
storm.windKt = 9999;
},
];
for (const change of changes) {
const value = current();
change(value.storms[0]);
assert.throws(() => validateCycloneSnapshot(value));
}
const oversized = current();
oversized.storms[0].track = {
type: 'MultiLineString',
coordinates: Array.from({ length: 3 }, () =>
Array.from({ length: 9000 }, () => [1, 2]),
),
};
assert.throws(() => validateCycloneSnapshot(oversized), /Malformed/);
assert.throws(
() =>
validateCycloneSnapshot({
...fixture(),
storms: Array(33).fill(fixture().storms[0]),
}),
/Malformed/,
);
});
test('source is lazy and fixed-origin, caps bytes, and honors pre-abort', async () => {
const calls = [];
const source = createCycloneSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json(fixture());
},
});
assert.equal(calls.length, 0);
await source.getSnapshot();
assert.equal(calls[0].url, '/api/cyclones');
assert.equal(calls[0].options.redirect, 'error');
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls.length, 1);
const oversized = createCycloneSource({
fetchImpl: async () =>
new Response('{}', {
headers: { 'content-length': String(CYCLONE_RESPONSE_LIMIT + 1) },
}),
});
await assert.rejects(oversized.getSnapshot(), /too large/);
});
test('source deadline cancels request and removes timer on completion', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let signal;
const source = createCycloneSource({
timeoutMs: 10,
fetchImpl: async (_url, options) => {
signal = options.signal;
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = source.getSnapshot();
const rejected = assert.rejects(pending, /timed out/);
t.mock.timers.tick(11);
await rejected;
assert.equal(signal.aborted, true);
});
+52
View File
@@ -0,0 +1,52 @@
import {
CLASS_MODEL_REAL,
CLASS_MODEL_URL,
CLASS_SCALE_3D,
} from '../../data/aircraftClass.js';
import {
trailAnchorForModel,
visualCenterForModel,
} from '../../data/modelVisualAnchor.js';
import {
MODEL_BELLY_OFFSET_NATIVE,
MODEL_COLOR_BLEND_AMOUNT,
MODEL_NATIVE_RADIUS_M,
MODEL_SCALE,
PLANE_MODEL_URL,
} from './policy.js';
/**
* The glTF asset, scale and measured geometry a civil aircraft class renders
* with. Real per-class GLBs are baked to real-world meters (scale 1, measured
* belly and radius); classes without one share airplane.glb at the class's
* 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw
* the same model for the same class.
* @param {string} klass Aircraft class from `classifyAircraft`.
* @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number,
* blendAmount:number, visualCenterNative:object, trailAnchorNative:object}}
*/
export function civilAircraftModelSpec(klass) {
const real = CLASS_MODEL_REAL[klass];
if (real) {
return {
url: real.url,
scale: 1,
nativeRadiusM: real.radiusM,
bellyM: real.bellyM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(real.url),
trailAnchorNative: trailAnchorForModel(real.url),
};
}
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
return {
url,
scale,
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(url),
trailAnchorNative: trailAnchorForModel(url),
};
}
+14 -68
View File
@@ -1,14 +1,8 @@
import * as Cesium from 'cesium';
import {
CLASS_SCALE_2D,
CLASS_MODEL_REAL,
CLASS_SCALE_3D,
CLASS_MODEL_URL,
} from '../../data/aircraftClass.js';
import {
visualCenterForModel,
trailAnchorForModel,
} from '../../data/modelVisualAnchor.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -20,11 +14,6 @@ import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js';
import {
MIL_TINT,
GROUND_SCALE,
MODEL_COLOR_BLEND_AMOUNT,
MODEL_SCALE,
PLANE_MODEL_URL,
MODEL_NATIVE_RADIUS_M,
MODEL_BELLY_OFFSET_NATIVE,
COCKPIT_CONTACT_SIZE_PX,
COCKPIT_CIVILIAN_COLOR,
MODEL_ALT_CEIL_M,
@@ -100,31 +89,7 @@ export function createRendering({
function _modelSpec(klass) {
const cached = flightState._specCache.get(klass);
if (cached) return cached;
const real = CLASS_MODEL_REAL[klass];
let spec;
if (real) {
spec = {
url: real.url,
scale: 1,
nativeRadiusM: real.radiusM,
bellyM: real.bellyM,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(real.url),
trailAnchorNative: trailAnchorForModel(real.url),
};
} else {
const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1);
const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL;
spec = {
url,
scale,
nativeRadiusM: MODEL_NATIVE_RADIUS_M,
bellyM: MODEL_BELLY_OFFSET_NATIVE * scale,
blendAmount: MODEL_COLOR_BLEND_AMOUNT,
visualCenterNative: visualCenterForModel(url),
trailAnchorNative: trailAnchorForModel(url),
};
}
const spec = civilAircraftModelSpec(klass);
flightState._specCache.set(klass, spec);
return spec;
}
@@ -158,7 +123,7 @@ export function createRendering({
const isCockpitNear =
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = bb.color?.alpha ?? 1;
const freshnessAlpha = cyberSonarBaseAlpha(bb);
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
@@ -180,7 +145,7 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1);
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
}
/**
@@ -953,31 +918,11 @@ export function createRendering({
);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = new Set();
// 1. KEEP on-screen already-modeled (visible retained — no flicker for what you can see).
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
}
// 2. ADD on-screen NEW inside the add radius, nearest first (visible additions win the cap).
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
}
// 3. KEEP off-screen already-modeled (hysteresis, but LOWER priority than anything visible — so a
// retained off-screen model can never starve an on-screen plane that wants one; dropping it is
// invisible and it re-adds the moment it's back in view).
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
}
// 4. ADD off-screen NEW inside the add radius with any leftover slots.
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
}
modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => flightState._models.has(icao),
});
const toRelease = [];
for (const icao of flightState._models.keys()) {
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
@@ -1062,12 +1007,13 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor =
const layerBaseColor =
flightState._cockpitContactMode && !isCockpitNear
? isMilitaryIcao(icao24)
? MIL_TINT
: COCKPIT_CIVILIAN_COLOR
: _fleetBillboardColor(icao24);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
@@ -165,7 +166,7 @@ export function createFlightSnapshotRenderer({
// freshness × focus × horizon alpha composition.
bb.color = rendering
._fleetBillboardColor(icao24)
.withAlpha(bb.color?.alpha ?? 1);
.withAlpha(cyberSonarBaseAlpha(bb));
}
if (
prevMeta?.klass !== meta.klass ||
+2
View File
@@ -250,6 +250,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+24
View File
@@ -1,4 +1,8 @@
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID,
@@ -303,6 +307,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
<span data-replay-overlay-detail></span>
</div>`;
host.appendChild(layerState._replayVehicleOverlay);
layerState._replayVehicleOverlay._sonarIcons = [
...layerState._replayVehicleOverlay.querySelectorAll(
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
),
];
layerState._replayVehicleOverlay._sonarLabel =
layerState._replayVehicleOverlay.querySelector(
'.mission-replay-overlay-callout',
);
}
function hideReplayVehicleOverlay() {
@@ -502,6 +515,17 @@ export function createOverlays({ state: layerState, services, parts, source }) {
: windowPosition;
track.lastOverlayWindowPosition = renderedWindowPosition;
track.lastOverlayMode = mode;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(canvas.clientWidth, canvas.clientHeight)
: null;
const factor =
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
icon.style.opacity = String(factor);
if (layerState._replayVehicleOverlay._sonarLabel)
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
sonar ? sonar.label(factor) : 1,
);
layerState._replayVehicleOverlay.hidden = false;
layerState._replayVehicleOverlay.dataset.mode = mode;
layerState._replayVehicleOverlay.classList.toggle(
+1 -1
View File
@@ -268,7 +268,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
index < 0 || index >= layerState._launches.length - 1;
parts.replay.syncReplayButton();
const panelScroller = layerState._missionPanel.closest(
'.global-context-panel-inner',
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
);
if (panelScroller) panelScroller.scrollTop = 0;
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
for (const cyber of [true, false]) {
const body = { scrollTop: 180 };
const inner = { scrollTop: 90 };
const output = { closest: () => null, removeAttribute() {} };
const state = {
_missionPanel: {
querySelector: () => output,
closest(selector) {
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
return cyber ? body : inner;
},
},
_selectedLaunchId: 'mission-1',
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
_replayTracks: new Map(),
};
const panel = createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
});
panel.renderMissionPanel();
assert.equal(body.scrollTop, cyber ? 0 : 180);
assert.equal(inner.scrollTop, cyber ? 90 : 0);
}
});
+143
View File
@@ -0,0 +1,143 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import {
BAND_LABELS,
HEARD_BY_RECEIVER,
LOCAL_ADSB_COLOR,
SOURCE_LABELS,
} from './policy.js';
const DASH = '—';
function rounded(value, digits = 0) {
if (!Number.isFinite(value)) return null;
return Number(value.toFixed(digits));
}
function grouped(value) {
return value === null ? DASH : value.toLocaleString('en-US');
}
/**
* Title of a locally heard aircraft: callsign when decoded, else ICAO hex.
* @param {object} record Local ADS-B record.
* @returns {string}
*/
export function localAdsbTitle(record) {
return record?.callsign || String(record?.icao || '').toUpperCase();
}
function recordBands(record) {
if (Array.isArray(record?.bands) && record.bands.length) return record.bands;
return record?.band ? [record.band] : [];
}
function recordSources(record) {
if (Array.isArray(record?.sources) && record.sources.length)
return record.sources;
return record?.source ? [record.source] : [];
}
/**
* Whether an aircraft was heard only on 978 MHz UAT (its marker gets the
* UAT ring). One also heard on 1090 MHz draws as a 1090 aircraft.
* @param {object} record Local ADS-B record.
* @returns {boolean}
*/
export function localAdsbIsUat(record) {
const bands = recordBands(record);
return bands.includes('978') && !bands.includes('1090');
}
/**
* Receiver line of the click card, e.g. "Heard by your receiver · 1090 MHz ·
* browser SDR" or "… · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed".
* @param {object} record Local ADS-B record (merged: `bands`, `sources`).
* @returns {string}
*/
export function localAdsbReceiverLine(record) {
const bands = recordBands(record)
.map((band) => BAND_LABELS[band])
.filter(Boolean);
const sources = recordSources(record)
.map((source) => SOURCE_LABELS[source])
.filter(Boolean);
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
.filter(Boolean)
.join(' · ');
}
/**
* Where a heard aircraft came from, for the entity context's source field.
* @param {object} record Local ADS-B record.
* @returns {string}
*/
export function localAdsbSourceText(record) {
const sources = recordSources(record);
if (sources.includes('webusb') && sources.includes('feed'))
return 'Your RTL-SDR receiver and decoder feed';
if (sources.includes('feed')) return 'Your decoder feed';
return 'Your RTL-SDR receiver';
}
/**
* Class line of the click card, e.g. "Helicopter · A7". Empty when neither an
* ICAO type nor an emitter category backs the class.
* @param {object} record Local ADS-B record.
* @param {{klass?:string, evidence?:boolean}} [aircraftClass]
* @returns {string}
*/
export function localAdsbClassLine(record, aircraftClass = {}) {
if (!aircraftClass.evidence) return '';
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
return [label, record?.category].filter(Boolean).join(' · ');
}
/**
* Click-card presentation for one locally heard aircraft.
* @param {object} record Local ADS-B record.
* @param {number} nowMs Current epoch ms, for the position age.
* @param {object} [extras]
* @param {{klass:string, evidence:boolean}} [extras.aircraftClass] Class and
* whether a type or category backs it.
* @param {object|null} [extras.meta] adsbdb metadata (typeCode, typeName,
* airline).
* @param {object|null} [extras.route] A route already judged plausible.
* @returns {{title:string, details:string[], accent:string}}
*/
export function localAdsbCardModel(
record,
nowMs,
{ aircraftClass = null, meta = null, route = null } = {},
) {
const icao = String(record?.icao || '').toUpperCase();
const altitude = rounded(record?.altitudeFt);
const speed = rounded(record?.groundSpeedKt);
const track = rounded(record?.trackDeg);
const vertical = rounded(record?.verticalRateFpm);
const ageS = Number.isFinite(record?.lastPositionAt)
? Math.max(0, Math.round((nowMs - record.lastPositionAt) / 1000))
: null;
const messages = Math.max(0, Math.trunc(Number(record?.messageCount) || 0));
// Same identity and route lines the public flight card shows.
const identity = [meta?.airline, meta?.typeName || meta?.typeCode]
.filter(Boolean)
.join(' · ');
const routeLine =
route?.origin?.code && route?.destination?.code
? `${route.origin.code} → ${route.destination.code}`
: '';
return {
title: localAdsbTitle(record),
details: [
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
localAdsbClassLine(record, aircraftClass || {}),
identity,
routeLine,
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
localAdsbReceiverLine(record),
].filter(Boolean),
accent: LOCAL_ADSB_COLOR,
};
}
+166
View File
@@ -0,0 +1,166 @@
import { classifyAircraft } from '../../data/aircraftClass.js';
import { stickyText } from '../../data/aircraftMeta.js';
import {
ENRICH_DISPATCH_GAP_MS,
ENRICH_MAX_INFLIGHT,
} from '../flights/policy.js';
const ICAO = /^[0-9a-f]{6}$/i;
/** Airline-style callsigns only (LLL + digit); GA tails do not resolve. */
const AIRLINE_CALLSIGN = /^[A-Z]{3}\d/;
/** Bound on the per-session metadata map. */
const META_LIMIT = 2_000;
/**
* Class of a locally heard aircraft: the adsbdb ICAO type when known, else
* the emitter category it broadcasts. `evidence` is false when neither is
* known, so the card never presents the classifier's default as fact.
* @param {{category?:string|null}} record
* @param {{typeCode?:string|null}} [meta]
* @returns {{klass:string, evidence:boolean}}
*/
export function localAdsbClass(record, meta = null) {
const typeCode = meta?.typeCode || null;
const category = record?.category || null;
return {
klass: classifyAircraft({ typeCode, category }),
evidence: Boolean(typeCode) || /^(?:A[1-7]|B1)$/.test(category || ''),
};
}
/**
* adsbdb enrichment for Local ADS-B through the same cached proxy and the same
* rules as the public Flights layer: each key is asked for at most once per
* session, at most four requests are in flight and dispatches are dripped
* 200 ms apart. Only the selected aircraft (type + route) and aircraft that
* are about to render as 3D models (type) are ever looked up — never one
* request per contact per tick.
* @param {object} options
* @param {{getEnrichment?: Function}|null} options.source Flights source.
* @param {() => void} options.onChange Called when metadata arrives.
* @param {() => number} [options.now]
* @returns {object}
*/
export function createLocalAdsbEnrichment({
source,
onChange,
now = Date.now,
}) {
const meta = new Map();
const seen = new Set();
const queue = [];
let active = 0;
let lastDispatchAt = -Infinity;
let dripTimer = null;
let lifetime = new AbortController();
function entry(icao) {
let value = meta.get(icao);
if (!value) {
value = {};
meta.set(icao, value);
if (meta.size > META_LIMIT) meta.delete(meta.keys().next().value);
}
return value;
}
function enqueue(key, query, apply, priority) {
if (typeof source?.getEnrichment !== 'function' || seen.has(key)) return;
seen.add(key);
const job = { query, apply };
if (priority) queue.unshift(job);
else queue.push(job);
drain();
}
function drain() {
const signal = lifetime.signal;
while (!signal.aborted && active < ENRICH_MAX_INFLIGHT && queue.length) {
const wait = ENRICH_DISPATCH_GAP_MS - (now() - lastDispatchAt);
if (wait > 0) {
if (!dripTimer)
dripTimer = setTimeout(() => {
dripTimer = null;
drain();
}, wait);
return;
}
lastDispatchAt = now();
const job = queue.shift();
active += 1;
Promise.resolve()
.then(() => source.getEnrichment(job.query, { signal }))
.then((data) => {
if (!signal.aborted && data?.found) {
job.apply(data);
onChange?.();
}
})
.catch(() => {
/* enrichment never surfaces errors */
})
.finally(() => {
if (signal.aborted) return;
active -= 1;
drain();
});
}
}
return {
/** @param {string} icao @returns {object|null} */
get(icao) {
return meta.get(String(icao || '').toLowerCase()) || null;
},
/**
* Ask for the aircraft's type (and, when selected, its route).
* @param {object} record Local ADS-B record.
* @param {{selected?: boolean}} [options]
*/
request(record, { selected = false } = {}) {
const icao = String(record?.icao || '').toLowerCase();
if (!ICAO.test(icao)) return;
enqueue(
`t:${icao}`,
{ kind: 'type', id: icao },
(data) => {
const value = entry(icao);
value.typeCode = stickyText(data.typeCode, value.typeCode) || null;
value.typeName = stickyText(data.typeName, value.typeName) || null;
value.registration =
stickyText(data.registration, value.registration) || null;
},
selected,
);
const callsign = String(record.callsign || '')
.trim()
.toUpperCase();
if (!selected || !AIRLINE_CALLSIGN.test(callsign)) return;
enqueue(
`r:${callsign}`,
{ kind: 'route', id: callsign },
(data) => {
const value = entry(icao);
value.airline = stickyText(data.airline, value.airline) || null;
if (data.origin && data.destination)
value.route = {
callsign,
origin: data.origin,
destination: data.destination,
};
},
true,
);
},
destroy() {
lifetime.abort();
lifetime = new AbortController();
clearTimeout(dripTimer);
dripTimer = null;
queue.length = 0;
active = 0;
},
};
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Names for the server's decoder feeds, shared by the Local ADS-B row status
* and the Radio card. Pure; no imports, so any surface may use it.
*/
/**
* Display name of one feed: its band, or its ordinal label ("978 MHz UAT #2")
* when another configured feed shares the band.
* @param {object} feed Feed status from the route.
* @param {object[]} [feeds] Every configured feed.
* @returns {string}
*/
export function localReceiverFeedName(feed, feeds = []) {
const band = feed?.band;
if (!band) return feed?.label || 'feed';
const sharing = feeds.filter((other) => other?.band === band);
if (sharing.length < 2) return band;
if (typeof feed.label === 'string' && feed.label.includes('#'))
return feed.label;
return `${feed.label || band} #${sharing.indexOf(feed) + 1}`;
}
+253
View File
@@ -0,0 +1,253 @@
import {
localAdsbRecordIsLive,
localAdsbRecordIsNewer,
} from '../../sources/adsbRecords.js';
/** Same-origin route served by `server/providers/local-receivers.js`. */
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
export const LOCAL_RECEIVER_POLL_MS = 1_000;
/**
* Browser-side deadline for one route request, body included. The server
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
*/
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
function abortError() {
const error = new Error('Local receiver request aborted');
error.name = 'AbortError';
return error;
}
/** Reject with an AbortError as soon as `signal` aborts. */
function untilAborted(promise, signal) {
if (signal.aborted) return Promise.reject(abortError());
let onAbort = null;
const aborted = new Promise((_, reject) => {
onAbort = () => reject(abortError());
signal.addEventListener('abort', onAbort, { once: true });
});
return Promise.race([promise, aborted]).finally(() =>
signal.removeEventListener('abort', onAbort),
);
}
function shiftTime(value, offset) {
return Number.isFinite(value) ? value + offset : value;
}
/**
* Browser poller for the server's local decoder feeds.
*
* Polls only between `start()` and `stop()` (the Local ADS-B layer calls them
* on enable/disable), and only while the route reports `configured`: an
* unconfigured server, or a build without the route, stops the loop after one
* request. `probe()` makes that one request without polling, so the Radio card
* can show which feeds exist before the layer is turned on.
*
* Records are rebased from the server clock to this browser's clock and kept
* by ICAO and band for up to 60 s after their last message, so a feed that
* drops an aircraft or goes briefly unreachable does not erase it at once.
* @param {object} [options]
* @param {typeof fetch} [options.fetchImpl]
* @param {string} [options.url]
* @param {number} [options.intervalMs]
* @param {number} [options.requestTimeoutMs] Deadline per request.
* @param {() => number} [options.now]
* @returns {object} Feed session: start, stop, probe, getState, subscribe.
*/
export function createLocalReceiverFeeds({
fetchImpl = (...args) => globalThis.fetch(...args),
url = LOCAL_RECEIVERS_URL,
intervalMs = LOCAL_RECEIVER_POLL_MS,
requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS,
now = Date.now,
} = {}) {
const listeners = new Set();
const retained = new Map();
/** Abort controllers of requests still in flight. */
const inFlight = new Set();
let state = {
configured: null,
polling: false,
feeds: [],
records: [],
error: null,
updatedAt: null,
};
let generation = 0;
let timer = null;
let probing = null;
function emit(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener(state);
}
function retain(records, at) {
for (const record of records) {
if (!record?.icao) continue;
// Two feeds on one band both report an aircraft: keep the fresher
// record, never simply the one listed (or polled) last.
const key = `${record.icao}|${record.band}`;
const current = retained.get(key);
if (!current || !localAdsbRecordIsNewer(current, record))
retained.set(key, record);
}
for (const [key, record] of retained) {
if (!localAdsbRecordIsLive(record, at)) retained.delete(key);
}
return [...retained.values()];
}
/** One route request with a deadline through the body, abortable by stop. */
async function request() {
const controller = new AbortController();
inFlight.add(controller);
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
try {
const response = await untilAborted(
fetchImpl(url, {
cache: 'no-store',
headers: { Accept: 'application/json' },
signal: controller.signal,
}),
controller.signal,
);
if (!response?.ok) throw new Error('local receiver route unavailable');
return await untilAborted(response.json(), controller.signal);
} finally {
clearTimeout(timer);
inFlight.delete(controller);
}
}
function abortInFlight() {
for (const controller of inFlight) controller.abort();
inFlight.clear();
}
function apply(body) {
if (body?.configured !== true) {
retained.clear();
emit({
configured: false,
polling: false,
feeds: [],
records: [],
error: null,
});
return false;
}
const at = now();
const offset = Number.isFinite(body.generatedAt)
? at - body.generatedAt
: 0;
const records = (Array.isArray(body.records) ? body.records : []).map(
(record) => ({
...record,
lastPositionAt: shiftTime(record.lastPositionAt, offset),
lastMessageAt: shiftTime(record.lastMessageAt, offset),
}),
);
emit({
configured: true,
feeds: Array.isArray(body.feeds) ? body.feeds : [],
records: retain(records, at),
error: null,
updatedAt: at,
});
return true;
}
function fail() {
// A route that never answered means there is nothing to poll; a route
// that answered before is reported unreachable and polled again.
if (state.configured !== true) {
emit({ configured: false, polling: false, error: 'unavailable' });
return false;
}
emit({
feeds: state.feeds.map((feed) =>
feed.status === 'invalid'
? feed
: { ...feed, status: 'unreachable', aircraft: 0 },
),
records: retain([], now()),
error: 'unavailable',
});
return true;
}
async function tick(current) {
timer = null;
let keepPolling;
try {
const body = await request();
if (current !== generation) return;
keepPolling = apply(body);
} catch {
if (current !== generation) return;
keepPolling = fail();
}
if (!keepPolling) {
generation += 1;
return;
}
timer = setTimeout(() => void tick(current), intervalMs);
}
return {
/** Start polling (no-op while already polling). */
start() {
if (state.polling) return;
generation += 1;
emit({ polling: true });
void tick(generation);
},
/** Stop polling; the last feed statuses stay readable. */
stop() {
generation += 1;
clearTimeout(timer);
timer = null;
abortInFlight();
if (state.polling) emit({ polling: false });
},
/**
* Ask the route once whether feeds are configured, without polling.
* @returns {Promise<void>}
*/
probe() {
if (state.polling || state.configured !== null) return Promise.resolve();
if (!probing) {
const current = generation;
probing = request()
.then((body) => {
if (current === generation && !state.polling) apply(body);
})
.catch(() => {
if (current === generation && !state.polling)
emit({ configured: false, error: 'unavailable' });
})
.finally(() => {
probing = null;
});
}
return probing;
},
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
this.stop();
listeners.clear();
retained.clear();
},
};
}
+256
View File
@@ -0,0 +1,256 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function route(bodies) {
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
const next = bodies.length > 1 ? bodies.shift() : bodies[0];
if (next instanceof Error) throw next;
if (next === 404) return { ok: false, status: 404 };
return { ok: true, json: async () => structuredClone(next) };
};
return { calls, fetchImpl };
}
function payload(records, generatedAt = 1_000_000) {
return {
configured: true,
generatedAt,
feeds: [
{ band: '1090', label: '1090 MHz', status: 'live', aircraft: 1 },
{ band: '978', label: '978 MHz UAT', status: 'live', aircraft: 1 },
],
records,
};
}
function aircraft(overrides = {}) {
return {
icao: 'ae5d8a',
lat: 30.27,
lon: -97.79,
lastPositionAt: 999_000,
lastMessageAt: 999_500,
band: '1090',
source: 'feed',
...overrides,
};
}
test('an unconfigured server is asked once and never polled', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([
{ configured: false, feeds: [], records: [] },
]);
const feeds = createLocalReceiverFeeds({ fetchImpl });
feeds.start();
await flush();
assert.deepEqual(calls, [LOCAL_RECEIVERS_URL]);
assert.equal(feeds.getState().configured, false);
assert.equal(feeds.getState().polling, false);
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 1);
});
test('a build without the route stops after one failed request', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([404]);
const feeds = createLocalReceiverFeeds({ fetchImpl });
feeds.start();
await flush();
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 1);
assert.equal(feeds.getState().configured, false);
});
test('configured feeds poll every second between start and stop, on the browser clock', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([payload([aircraft()])]);
const clock = 1_000_250;
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
const seen = [];
feeds.subscribe((state) => seen.push(state.polling));
feeds.start();
await flush();
const state = feeds.getState();
assert.equal(state.configured, true);
assert.equal(state.polling, true);
assert.equal(state.feeds.length, 2);
// Server generated at 1_000_000; received at 1_000_250: rebased +250 ms.
assert.equal(state.records[0].lastPositionAt, 999_250);
assert.equal(state.records[0].lastMessageAt, 999_750);
t.mock.timers.tick(1_000);
await flush();
t.mock.timers.tick(1_000);
await flush();
assert.equal(calls.length, 3);
feeds.stop();
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 3, 'no polling once stopped');
assert.equal(feeds.getState().polling, false);
assert.equal(seen.at(-1), false);
});
test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const bodies = [
payload([aircraft(), aircraft({ band: '978' })], 1_000_000),
payload([aircraft()], 1_010_000),
payload([], 1_060_000),
];
let clock = 1_000_000;
const { fetchImpl } = route(bodies);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
feeds.start();
await flush();
assert.equal(feeds.getState().records.length, 2);
clock = 1_010_000;
t.mock.timers.tick(1_000);
await flush();
assert.deepEqual(
feeds.getState().records.map((record) => record.band),
['1090', '978'],
'the 978 reception is remembered',
);
clock = 1_060_000;
t.mock.timers.tick(1_000);
await flush();
assert.deepEqual(feeds.getState().records, [], 'forgotten after 60 s');
feeds.stop();
});
test('a route that answered before is reported unreachable and polled again', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([
payload([aircraft()]),
new TypeError('network down'),
payload([aircraft()]),
]);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
feeds.start();
await flush();
t.mock.timers.tick(1_000);
await flush();
assert.deepEqual(
feeds.getState().feeds.map((feed) => feed.status),
['unreachable', 'unreachable'],
);
assert.equal(feeds.getState().records.length, 1, 'recent records remain');
t.mock.timers.tick(1_000);
await flush();
assert.equal(calls.length, 3);
assert.equal(feeds.getState().feeds[0].status, 'live');
feeds.stop();
});
test('probe asks once without polling so the Radio card can list feeds', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { calls, fetchImpl } = route([payload([aircraft()])]);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
await Promise.all([feeds.probe(), feeds.probe()]);
assert.equal(calls.length, 1);
assert.equal(feeds.getState().configured, true);
assert.equal(feeds.getState().polling, false);
await feeds.probe();
t.mock.timers.tick(5_000);
await flush();
assert.equal(calls.length, 1);
});
test('two feeds on one band keep the fresher record, whichever is listed last', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const fresh = aircraft({ lastPositionAt: 999_000, lastMessageAt: 999_500 });
// A second 1090 feed that heard the aircraft earlier and has no position.
const older = aircraft({
lat: null,
lon: null,
lastPositionAt: null,
lastMessageAt: 990_000,
});
const { fetchImpl } = route([
payload([fresh, older], 1_000_000),
payload([older], 1_000_000),
]);
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
feeds.start();
await flush();
let [kept] = feeds.getState().records;
assert.equal(feeds.getState().records.length, 1);
assert.equal(kept.lastPositionAt, 999_000, 'same response');
t.mock.timers.tick(1_000);
await flush();
[kept] = feeds.getState().records;
assert.equal(kept.lastPositionAt, 999_000, 'next poll');
assert.equal(kept.lat, 30.27);
feeds.stop();
});
/** A route whose requests never answer until their signal aborts. */
function stalledRoute({ stallBody = false } = {}) {
const signals = [];
const hang = (signal) =>
new Promise((_, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('aborted', 'AbortError')),
);
});
const fetchImpl = async (url, options = {}) => {
signals.push(options.signal);
if (!stallBody) return hang(options.signal);
return { ok: true, json: () => hang(options.signal) };
};
return { signals, fetchImpl };
}
test('a stalled feed request is abandoned at its deadline and polling continues', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
for (const stallBody of [false, true]) {
const { signals, fetchImpl } = stalledRoute({ stallBody });
const feeds = createLocalReceiverFeeds({
fetchImpl,
now: () => 1_000_000,
requestTimeoutMs: 3_000,
});
feeds.start();
await flush();
assert.equal(signals.length, 1);
assert.ok(signals[0], 'every request carries an abort signal');
t.mock.timers.tick(3_000);
await flush();
assert.equal(signals[0].aborted, true, `aborted (stallBody ${stallBody})`);
assert.equal(feeds.getState().configured, false, 'never answered');
feeds.destroy();
}
});
test('stop() aborts the request in flight so start() is never blocked by it', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { signals, fetchImpl } = stalledRoute();
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
feeds.start();
await flush();
feeds.stop();
assert.equal(signals[0].aborted, true);
feeds.start();
await flush();
feeds.stop();
feeds.start();
await flush();
assert.equal(signals.length, 3);
assert.deepEqual(
signals.map((signal) => signal.aborted),
[true, true, false],
'no disable/enable cycle leaves an old request outstanding',
);
feeds.destroy();
assert.equal(signals[2].aborted, true);
});
+846
View File
@@ -0,0 +1,846 @@
import * as Cesium from 'cesium';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
import { aircraftIcon } from '../../data/aircraftIcons.js';
import {
horizonOccluder,
screenProjectedRotation,
stabilizeScreenRotation,
} from '../../data/iconOrientation.js';
import { isPointerFree } from '../../data/inputOwnership.js';
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
import { routePlausible } from '../../data/routePlausible.js';
import {
localAdsbPositionIsFresh,
mergeLocalAdsbRecords,
summarizeLocalAdsb,
} from '../../sources/adsbRecords.js';
import { GROUND_SCALE } from '../flights/policy.js';
import {
localAdsbCardModel,
localAdsbIsUat,
localAdsbSourceText,
localAdsbTitle,
} from './card.js';
import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js';
import { createLocalAdsbModels } from './models.js';
import { LocalAdsbMotion } from './motion.js';
import {
ENTITY_PREFIX,
LAYER_ID,
LAYER_NAME,
LAYER_SOURCE,
LOCAL_ADSB_COLOR,
LOCAL_ADSB_SYNC_MS,
LOCAL_ADSB_TICK_MS,
LOCAL_ADSB_UAT_RING_COLOR,
} from './policy.js';
import { localAdsbStatus } from './status.js';
export {
LAYER_ID as LOCAL_ADSB_LAYER_ID,
HEARD_BY_RECEIVER,
} from './policy.js';
export {
localAdsbCardModel,
localAdsbClassLine,
localAdsbIsUat,
localAdsbReceiverLine,
localAdsbTitle,
} from './card.js';
export { localAdsbClass } from './enrichment.js';
export { localAdsbStatus } from './status.js';
const FEET_TO_METERS = 0.3048;
const RENDER_HOLD_ID = 'local-adsb';
/** Picks looked through under a click for a local aircraft. */
const CLICK_DRILL_LIMIT = 8;
/** Trail lines pass through their aircraft; a click never stops on one. */
const TRAIL_PICK_PREFIX = 'gev-trail:';
/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */
const DEFAULT_DISPLAY = Object.freeze({
models3d: true,
models3dMode: 'proximity',
});
/**
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
*
* Two inputs feed it. The receiver is any object with `getState()` returning
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
* by the server from local 1090/978 MHz decoders; it polls only while this
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
*
* Aircraft get the public Flights treatment in magenta: a class from the
* emitter category or adsbdb type (`aircraftClass.js`), the class silhouette
* and scale, the Flights 3D models under the DISPLAY rail's 3D toggle, smooth
* real-time motion (`./motion.js`, no display delay) and, while selected, a
* trail of the positions the receiver heard.
*
* Markers are never followed by the camera. A marker disappears once its
* position is older than 60 s; a record without any message for 60 s is gone.
* @param {object} options
* @param {object} options.receiver Local receiver session.
* @param {object} [options.feeds] Decoder-feed session (start, stop,
* getState, subscribe).
* @param {object} options.services Render, context, picking, detection and
* readout operations supplied by the application; optionally `display`
* (`getParams()` → `{ models3d, models3dMode }`), `enrichment` (a source
* with `getEnrichment`), `trails` (`createTrail`), `geoid`
* (`ensureGeoidReady`, `geoidHeight`) and `groundSnap`
* (`createGroundSnap`).
* @param {() => number} [options.now] Clock, injectable for tests.
* @param {(url: string) => string} [options.resolveAsset] Model URL resolver.
* @param {(options: object) => Promise<object>} [options.loadModel] Model
* loader, injectable for tests.
* @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler]
* Click-handler factory, injectable for tests.
* @returns {object} Data-layer module.
*/
export function createLocalAdsbLayer({
receiver,
feeds = null,
services,
now = Date.now,
resolveAsset = (url) => url,
loadModel,
createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
}) {
if (typeof receiver?.getState !== 'function')
throw new TypeError('Local ADS-B requires a receiver session');
const {
governorRequestRender,
holdContinuousRender = () => {},
releaseContinuousRender = () => {},
} = services.render;
const {
registerEntityContext,
selectEntityContext,
clearSelectedEntityContextForLayer,
getSelectedEntityContext,
removeEntityContextsForLayer,
} = services.context;
const { registerPickOwner, unregisterPickOwner } = services.picking;
const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR);
const uatRingColor = Cesium.Color.fromCssColorString(
LOCAL_ADSB_UAT_RING_COLOR,
);
let viewer = null;
let dataSource = null;
let enabled = false;
let unsubscribe = null;
let unsubscribeFeeds = null;
let tickTimer = null;
let syncTimer = null;
let clickHandler = null;
let removePreRender = null;
let renderHeld = false;
let selectedId = null;
let selectedTrail = null;
let trailHeadSeq = 0;
let models = null;
let geoidReady = false;
let rejectedFixes = 0;
let state = receiver.getState();
const markers = new Map();
// Per-ICAO receptions (band|source -> last message) across merges.
const heardBy = new Map();
const enrichment = createLocalAdsbEnrichment({
source: services.enrichment || null,
onChange: () => {
for (const marker of markers.values()) refreshClass(marker);
refreshSelectedCard();
governorRequestRender('local-adsb-enrichment');
},
});
function displayPreferences() {
return services.display?.getParams?.() || DEFAULT_DISPLAY;
}
function markSourcesChanged(reason) {
services.detection?.markSourcesChanged?.(reason);
}
function mergedRecords(at = now(), receiverState = state) {
return mergeLocalAdsbRecords(
[receiverState.aircraft || [], feeds?.getState?.()?.records || []],
at,
heardBy,
);
}
function freshRecords(at = now()) {
return mergedRecords(at).filter((record) =>
localAdsbPositionIsFresh(record, at),
);
}
function markerVisible(marker) {
if (!viewer?.camera?.positionWC || !marker.position) return true;
return horizonOccluder(viewer.camera).isPointVisible(marker.position);
}
function markerRotation(marker) {
if (!viewer?.scene || !marker.position) return marker.lastRotation;
const projected = screenProjectedRotation(
viewer.scene,
marker.position,
marker.courseDeg ?? 0,
marker.lastRotation,
);
const stable = stabilizeScreenRotation(marker.lastRotation, projected);
if (stable !== null) marker.lastRotation = stable;
return marker.lastRotation;
}
function geoidN(lat, lon) {
if (!geoidReady || typeof services.geoid?.geoidHeight !== 'function')
return null;
try {
const value = services.geoid.geoidHeight(lat, lon);
return Number.isFinite(value) ? value : null;
} catch {
return null;
}
}
/** Barometric feet → ellipsoidal render metres, as the Flights layer does
* for a contact without a geometric altitude: baro + geoid N. */
function renderHeightM(marker, altitudeFt, lat, lon) {
if (marker.geoidN === null) marker.geoidN = geoidN(lat, lon);
const height = pickRenderAltitudeM({
geoAltM: null,
baroAltM: Number.isFinite(altitudeFt)
? altitudeFt * FEET_TO_METERS
: null,
onGround: false,
surfaceM: null,
geoidN: marker.geoidN,
});
return Number.isFinite(height) ? height : (marker.geoidN ?? 0);
}
function fixPosition(marker, fix) {
return Cesium.Cartesian3.fromDegrees(
fix.lon,
fix.lat,
renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon),
);
}
function updateDisplay(marker, at) {
const display = marker.motion.displayAt(at);
if (!display) return;
marker.lat = display.lat;
marker.lon = display.lon;
marker.position = Cesium.Cartesian3.fromDegrees(
display.lon,
display.lat,
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
Cesium.Ellipsoid.WGS84,
marker.position || new Cesium.Cartesian3(),
);
if (display.courseDeg !== null) marker.courseDeg = display.courseDeg;
}
function billboardScale(marker) {
return (
(CLASS_SCALE_2D[marker.klass] || 1) * (marker.onGround ? GROUND_SCALE : 1)
);
}
/** Class silhouette and per-class scale (×0.8 on the ground), magenta. */
function refreshClass(marker) {
const { klass, evidence } = localAdsbClass(
marker.record,
enrichment.get(marker.record.icao),
);
marker.evidence = evidence;
marker.klass = klass;
const billboard = marker.entity?.billboard;
if (!billboard) return;
if (marker.iconKind !== klass) {
marker.iconKind = klass;
billboard.image = aircraftIcon(klass);
}
const scale = billboardScale(marker);
if (marker.iconScale !== scale) {
marker.iconScale = scale;
billboard.scale = scale;
}
}
function plausibleRoute(marker, meta) {
const route = meta?.route;
if (!route || route.callsign !== marker.record.callsign) return null;
const record = marker.record;
const ok = routePlausible({
latDeg: marker.lat ?? record.lat,
lonDeg: marker.lon ?? record.lon,
altitudeM: Number.isFinite(record.altitudeFt)
? record.altitudeFt * FEET_TO_METERS
: null,
verticalRateMps: Number.isFinite(record.verticalRateFpm)
? (record.verticalRateFpm * FEET_TO_METERS) / 60
: null,
origin: route.origin,
destination: route.destination,
});
return ok ? route : null;
}
function cardModel(marker, at) {
const meta = enrichment.get(marker.record.icao);
return localAdsbCardModel(marker.record, at, {
aircraftClass: { klass: marker.klass, evidence: marker.evidence },
meta,
route: plausibleRoute(marker, meta),
});
}
function contextMetadata(id, marker) {
const record = marker.record;
const meta = enrichment.get(record.icao);
return {
id,
layerId: LAYER_ID,
dataSource,
layerName: LAYER_NAME,
source: localAdsbSourceText(record),
label: localAdsbTitle(record),
latitude: marker.lat ?? record.lat,
longitude: marker.lon ?? record.lon,
properties: {
icao: record.icao,
callsign: record.callsign,
category: record.category ?? null,
aircraftClass: marker.evidence ? marker.klass : null,
typeCode: meta?.typeCode ?? null,
altitudeFt: record.altitudeFt,
groundSpeedKt: record.groundSpeedKt,
trackDeg: record.trackDeg,
verticalRateFpm: record.verticalRateFpm,
lastPositionAt: record.lastPositionAt,
messageCount: record.messageCount,
receiverSource: record.source,
bands: record.bands,
sources: record.sources,
},
};
}
function uatRing(marker) {
return new Cesium.PointGraphics({
pixelSize: 24,
color: Cesium.Color.TRANSPARENT,
outlineColor: uatRingColor,
outlineWidth: 1.5,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
show: new Cesium.CallbackProperty(() => markerVisible(marker), false),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
2_000_000,
),
});
}
// ── Trail of the selected aircraft ──────────────────────────────────────
function releaseTrail() {
if (!selectedTrail) return;
selectedTrail.trail?.destroy();
if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) {
try {
viewer.entities.remove(selectedTrail.head);
} catch {
/* torn down */
}
}
selectedTrail = null;
}
function refreshTrail() {
const marker = selectedId ? markers.get(selectedId) : null;
if (!marker || !selectedTrail?.trail) return;
const positions = marker.motion.fixes.map((fix) =>
fixPosition(marker, fix),
);
selectedTrail.lastFix = positions.at(-1) || null;
selectedTrail.trail.setPositions(positions);
}
function startTrail(marker) {
releaseTrail();
const createTrail = services.trails?.createTrail;
if (typeof createTrail !== 'function' || !viewer?.entities) return;
selectedTrail = {
id: marker.id,
trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }),
head: null,
lastFix: null,
};
// Live head: last heard fix → the extrapolated marker, every frame.
selectedTrail.head = viewer.entities.add({
// 'gev-trail' namespace: claimed by the trail pick owner so a click on
// the head segment never reads as empty space.
id: `gev-trail:local-adsb-head-${++trailHeadSeq}`,
polyline: {
positions: new Cesium.CallbackProperty(() => {
const current = markers.get(selectedTrail?.id);
const from = selectedTrail?.lastFix;
if (!current?.position || !from) return [];
return [from, Cesium.Cartesian3.clone(current.position)];
}, false),
width: 2.5,
material: color.withAlpha(0.9),
depthFailMaterial: color.withAlpha(0.45),
arcType: Cesium.ArcType.GEODESIC,
},
});
refreshTrail();
}
// ── Markers ─────────────────────────────────────────────────────────────
function upsertMarker(record, at) {
const id = `${ENTITY_PREFIX}${record.icao}`;
let marker = markers.get(id);
if (!marker) {
marker = {
id,
entity: null,
record,
motion: new LocalAdsbMotion(),
position: null,
lat: null,
lon: null,
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null,
lastRotation: 0,
klass: null,
evidence: false,
iconKind: null,
iconScale: null,
onGround: Boolean(record.onGround),
geoidN: null,
modelOwnsVisual: false,
};
markers.set(id, marker);
}
marker.record = record;
marker.onGround = Boolean(record.onGround);
const rejectedBefore = marker.motion.rejectedFixes;
const newFix = marker.motion.observe(record, at);
rejectedFixes += marker.motion.rejectedFixes - rejectedBefore;
updateDisplay(marker, at);
if (!marker.entity) {
marker.entity = dataSource.entities.add({
id,
position: new Cesium.CallbackProperty(() => marker.position, false),
billboard: {
image: aircraftIcon('airliner'),
width: 20,
height: 20,
scale: 1,
color,
sizeInMeters: false,
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
alignedAxis: Cesium.Cartesian3.ZERO,
rotation: new Cesium.CallbackProperty(
() => markerRotation(marker),
false,
),
show: new Cesium.CallbackProperty(
() => !marker.modelOwnsVisual && markerVisible(marker),
false,
),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
2_000_000,
),
},
});
marker.entity.gevTrackedId = id;
marker.entity.gevDisplayPosition = () => marker.position;
}
refreshClass(marker);
const uat = localAdsbIsUat(record);
if (uat !== Boolean(marker.entity.point))
marker.entity.point = uat ? uatRing(marker) : undefined;
marker.entity.gevLabelModel = cardModel(marker, at);
registerEntityContext(marker.entity, contextMetadata(id, marker));
if (newFix && id === selectedId) refreshTrail();
return id;
}
function clearSelection({ evicted = false } = {}) {
if (!selectedId) return;
selectedId = null;
releaseTrail();
clearSelectedEntityContextForLayer(LAYER_ID, { evicted });
}
function refreshSelectedCard() {
const marker = selectedId ? markers.get(selectedId) : null;
if (!marker) return;
marker.entity.gevLabelModel = cardModel(marker, now());
services.overlays?.refreshReadout?.(marker.entity);
}
function updateRenderHold() {
const want = enabled && markers.size > 0;
if (want === renderHeld) return;
renderHeld = want;
if (want) holdContinuousRender(RENDER_HOLD_ID);
else releaseContinuousRender(RENDER_HOLD_ID);
}
function requestEnrichment() {
const selected = selectedId ? markers.get(selectedId) : null;
if (selected) enrichment.request(selected.record, { selected: true });
for (const id of models?.eligible || []) {
const marker = markers.get(id);
if (marker) enrichment.request(marker.record);
}
}
function sync() {
clearTimeout(syncTimer);
syncTimer = null;
if (!dataSource) return;
const at = now();
const live = new Set();
let added = false;
if (enabled) {
for (const record of freshRecords(at)) {
const id = `${ENTITY_PREFIX}${record.icao}`;
if (!markers.has(id)) added = true;
live.add(upsertMarker(record, at));
}
}
let removed = false;
for (const [id, marker] of markers) {
if (live.has(id)) continue;
if (marker.entity) dataSource.entities.remove(marker.entity);
markers.delete(id);
removed = true;
}
// Expired aircraft take their 3D models (and pending loads) with them.
if (removed) models?.retain(markers);
if (selectedId && !live.has(selectedId))
clearSelection({ evicted: enabled });
removeEntityContextsForLayer(LAYER_ID, { retainIds: live });
if (selectedId) {
const selected = markers.get(selectedId);
if (getSelectedEntityContext()?.id !== selectedId) {
selectedId = null;
releaseTrail();
} else services.overlays?.refreshReadout?.(selected.entity);
}
requestEnrichment();
updateRenderHold();
if (live.size || removed) governorRequestRender('local-adsb-update');
// Detection re-solves labels only when the set of contacts changes, not
// on every position report.
if (added || removed) markSourcesChanged('local-adsb-update');
}
/** Per-frame pass: smooth positions and courses, then 3D models. */
function frame() {
if (!enabled || !markers.size) return;
const at = now();
for (const marker of markers.values()) updateDisplay(marker, at);
models?.frame(markers, displayPreferences(), at);
}
function scheduleSync(nextState) {
state = nextState;
requestSync();
}
function requestSync() {
if (!enabled || syncTimer) return;
syncTimer = setTimeout(sync, LOCAL_ADSB_SYNC_MS);
}
function selectMarker(id) {
const marker = markers.get(id);
if (!marker) return false;
const reselect = id === selectedId && selectedTrail?.id === id;
selectedId = id;
enrichment.request(marker.record, { selected: true });
marker.entity.gevLabelModel = cardModel(marker, now());
selectEntityContext(marker.entity);
if (!reselect) startTrail(marker);
governorRequestRender('local-adsb-selection');
return true;
}
function pickedId(picked) {
const resolved = services.picking.resolvePickId?.(picked);
if (resolved !== undefined) return resolved;
if (typeof picked?.id === 'string') return picked.id;
if (typeof picked?.id?.id === 'string') return picked.id.id;
return typeof picked?.primitive?.id === 'string'
? picked.primitive.id
: null;
}
function ownedElsewhere(id) {
return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id);
}
/**
* What a click at `position` means for this layer: a local aircraft id,
* `'other'` when a sibling layer's contact is under the pointer, `'trail'`
* for a trail line, or null for empty map.
*
* The frontmost pick decides, except that something no layer owns (a
* photoreal tile, a ring) or a trail line drawn over or through a local
* aircraft does not hide it: the click looks underneath for the aircraft.
*/
function clickTarget(position) {
const scene = viewer.scene;
const top = pickedId(scene.pick(position));
if (top && markers.has(top)) return top;
const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX);
if (ownedElsewhere(top) && !trail) return 'other';
let hits = [];
try {
hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || [];
} catch {
hits = [];
}
for (const hit of hits) {
const id = pickedId(hit);
if (id && markers.has(id)) return id;
if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue;
// A sibling layer's contact above ours takes the click.
if (ownedElsewhere(id)) return 'other';
}
return trail ? 'trail' : null;
}
function installInteraction() {
if (clickHandler || !viewer?.scene?.canvas) return;
clickHandler = createInputHandler(viewer.scene.canvas);
clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree() || !enabled) return;
const target = clickTarget(click.position);
// Clicking a local aircraft always (re)selects it and republishes its
// card, including the one already selected: its card may have been
// displaced, and a click on a marker must never read as "deselect".
if (target && markers.has(target)) {
selectMarker(target);
return;
}
// A click on a trail line is not empty map.
if (target === 'trail' || !selectedId) return;
// Another contact or empty map releases only this layer's selection,
// leaving a sibling layer's new selection intact.
clearSelection();
governorRequestRender('local-adsb-selection');
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function releaseInteraction() {
clickHandler?.destroy();
clickHandler = null;
}
function installFrame() {
if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return;
removePreRender = viewer.scene.preRender.addEventListener(frame);
}
function releaseFrame() {
removePreRender?.();
removePreRender = null;
}
function positionRows(maxCount = 500) {
if (!enabled) return [];
const rows = [];
for (const marker of markers.values()) {
if (rows.length >= maxCount) break;
if (!marker.position) continue;
const carto = Cesium.Cartographic.fromCartesian(marker.position);
rows.push({
id: marker.record.icao,
label: localAdsbTitle(marker.record),
callsign: marker.record.callsign,
position: Cesium.Cartesian3.clone(marker.position),
latitude: marker.lat,
longitude: marker.lon,
altitudeM: carto?.height ?? 0,
});
}
return rows;
}
return {
id: LAYER_ID,
name: LAYER_NAME,
icon: '📡',
source: LAYER_SOURCE,
updateInterval: 0,
statsRefreshInterval: LOCAL_ADSB_TICK_MS,
/** The shared receiver session, also driven by the Radio panel card. */
receiver,
/** Decoder-feed session; the Radio card shows its one-line summary. */
feeds,
init(nextViewer) {
viewer = nextViewer;
dataSource = new Cesium.CustomDataSource(LAYER_ID);
dataSource.show = false;
viewer.dataSources.add(dataSource);
if (viewer.scene?.primitives) {
models = createLocalAdsbModels({
viewer,
color,
resolveAsset,
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
...(loadModel ? { loadModel } : {}),
});
}
services.geoid
?.ensureGeoidReady?.()
?.then(() => {
geoidReady = true;
for (const marker of markers.values()) marker.geoidN = null;
})
?.catch(() => {
/* the baro path stays un-geoid-corrected */
});
unsubscribe = receiver.subscribe?.(scheduleSync) || null;
unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null;
return true;
},
async enable() {
enabled = true;
if (dataSource) dataSource.show = true;
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
installInteraction();
installFrame();
clearInterval(tickTimer);
tickTimer = setInterval(sync, LOCAL_ADSB_TICK_MS);
feeds?.start?.();
// The layer asks the shared tuner for 1090 MHz. Disabling it leaves the
// receiver in whatever mode the Radio card shows, so turning the layer
// off never starts FM audio on its own.
if (receiver.getState().mode !== 'adsb') await receiver.setMode('adsb');
state = receiver.getState();
sync();
return true;
},
async disable() {
enabled = false;
feeds?.stop?.();
clearInterval(tickTimer);
tickTimer = null;
releaseInteraction();
releaseFrame();
unregisterPickOwner(LAYER_ID);
sync();
models?.clear();
if (dataSource) dataSource.show = false;
markSourcesChanged('local-adsb-disabled');
return true;
},
async update() {
state = receiver.getState();
sync();
frame();
return true;
},
destroy() {
enabled = false;
clearInterval(tickTimer);
clearTimeout(syncTimer);
tickTimer = null;
syncTimer = null;
releaseInteraction();
releaseFrame();
unregisterPickOwner(LAYER_ID);
unsubscribe?.();
unsubscribe = null;
unsubscribeFeeds?.();
unsubscribeFeeds = null;
feeds?.destroy?.();
enrichment.destroy();
clearSelection();
removeEntityContextsForLayer(LAYER_ID);
markers.clear();
updateRenderHold();
models?.destroy();
models = null;
if (viewer && dataSource) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
void receiver.destroy?.();
},
getStats() {
const current = receiver.getState();
const at = now();
const records = mergedRecords(at, current);
const { heard, positioned } = summarizeLocalAdsb(records, at);
const lastUpdate =
records.reduce(
(latest, record) => Math.max(latest, record.lastMessageAt || 0),
0,
) || null;
return {
count: positioned,
lastUpdate,
source: LAYER_SOURCE,
// Fixes refused by the speed check: the browser decoder's own count
// plus the layer's check on every merged record.
rejectedPositions:
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
...localAdsbStatus({
receiver: current,
feedState: feeds?.getState?.() || null,
heard,
}),
};
},
/**
* Select a heard aircraft's marker and publish its readout card, exactly
* as a click does. The camera does not follow it.
* @param {string} icao Lowercase ICAO hex.
* @returns {boolean} Whether a fresh marker was selected.
*/
selectAircraft(icao) {
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
},
getAllPositions(maxCount = 500) {
return positionRows(maxCount);
},
getDetectableObjects(options = {}) {
return positionRows(options.maxCount).map((row) => ({
position: row.position,
sourceId: row.id,
// Label only a decoded callsign; never substitute the raw ICAO hex.
id: row.callsign || '',
type: 'AIR',
skipLabel: false,
}));
},
};
}
File diff suppressed because it is too large. Load diff
+282
View File
@@ -0,0 +1,282 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
import {
FLEET_DR_INTERVAL_MS,
MODEL_ALL_ADD_M,
MODEL_ALL_KEEP_M,
MODEL_ALT_CEIL_M,
MODEL_HEADING_OFFSET_DEG,
MODEL_MAX,
MODEL_MAX_ALL,
MODEL_MIN_PX,
MODEL_PROX_ADD_M,
MODEL_PROX_KEEP_M,
} from '../flights/policy.js';
const specKey = (spec) => `${spec.url}@${spec.scale}`;
/**
* 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle.
*
* Mirrors the public Flights fleet: models only below the altitude ceiling,
* the same proximity/all caps and add/keep radii, the same visible-first
* eligibility (`selectModelEligible`), the same per-class asset and scale
* (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here
* magenta, as Flights tints military amber). Each model owns its matrix. A
* billboard stays the visual until its model is loaded AND placed; a grounded
* aircraft's model rides the one-shot ground snap plus the class belly
* offset, and without a resolved ground it stays a billboard.
* @param {object} options
* @param {object} options.viewer Cesium viewer.
* @param {Cesium.Color} options.color Model tint.
* @param {(url: string) => string} options.resolveAsset
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
* @param {(options: object) => Promise<object>} [options.loadModel]
* @returns {object}
*/
export function createLocalAdsbModels({
viewer,
color,
resolveAsset,
groundSnap = null,
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
}) {
const collection = new Cesium.PrimitiveCollection();
viewer.scene.primitives.add(collection);
/** @type {Map<string, object>} id -> admitted model */
const models = new Map();
/** @type {Map<string, number>} id -> generation of an in-flight load */
const pending = new Map();
let generation = 0;
let destroyed = false;
let lastEligibilityAt = 0;
let eligible = new Set();
const scratchHpr = new Cesium.HeadingPitchRoll();
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
const scratchCarto = new Cesium.Cartographic();
const scratchGround = new Cesium.Cartesian3();
function release(id) {
pending.delete(id);
const model = models.get(id);
if (!model) return;
models.delete(id);
if (!collection.isDestroyed()) collection.remove(model);
groundSnap?.forget?.(id);
}
function releaseAll() {
for (const id of [...models.keys()]) release(id);
pending.clear();
generation += 1;
}
async function ensure(id, marker, cap) {
const spec = civilAircraftModelSpec(marker.klass);
const key = specKey(spec);
const current = models.get(id);
if (current && current._gevSpecKey === key) return;
if (current) release(id);
if (pending.has(id) || models.size + pending.size >= cap) return;
const loadGeneration = generation;
pending.set(id, loadGeneration);
let model;
try {
model = await loadModel({
url: resolveAsset(spec.url),
asynchronous: false,
minimumPixelSize: MODEL_MIN_PX,
scale: spec.scale,
color,
colorBlendMode: Cesium.ColorBlendMode.MIX,
colorBlendAmount: spec.blendAmount,
id,
});
} catch {
if (pending.get(id) === loadGeneration) pending.delete(id);
return;
}
const stale =
destroyed ||
pending.get(id) !== loadGeneration ||
generation !== loadGeneration ||
!eligible.has(id) ||
models.has(id) ||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
if (pending.get(id) === loadGeneration) pending.delete(id);
if (stale) {
model.destroy?.();
return;
}
model.id = id;
model._gevSpecKey = key;
model._gevBellyM = spec.bellyM;
// Admitted, not yet the visual: placement turns it on.
model.show = false;
collection.add(model);
models.set(id, model);
}
function regime(preferences) {
if (!preferences?.models3d) return false;
const height = viewer.camera?.positionCartographic?.height ?? Infinity;
return height < MODEL_ALT_CEIL_M;
}
function refreshEligibility(markers, preferences) {
const all = preferences.models3dMode === 'all';
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
const camera = viewer.camera;
const cameraPosition = camera.positionWC;
const cull = camera.frustum.computeCullingVolume(
cameraPosition,
camera.directionWC,
camera.upWC,
);
const candidates = [];
for (const [id, marker] of markers) {
if (!marker.position) continue;
const distanceSq = Cesium.Cartesian3.distanceSquared(
cameraPosition,
marker.position,
);
if (distanceSq > keepM * keepM) continue;
Cesium.Cartesian3.clone(marker.position, scratchSphere.center);
candidates.push([
id,
distanceSq,
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
]);
}
candidates.sort((a, b) => a[1] - b[1]);
eligible = selectModelEligible(candidates, {
cap,
addDistSq: addM * addM,
isModeled: (id) => models.has(id),
});
for (const id of [...models.keys(), ...pending.keys()])
if (!eligible.has(id)) release(id);
for (const id of eligible) void ensure(id, markers.get(id), cap);
}
function placement(id, marker) {
if (!marker.onGround) return marker.position;
const model = models.get(id);
// The vertical sample must hit the tile skin, not this aircraft's own
// billboard or model.
const height = groundSnap?.heightFor?.(viewer, id, marker.position, () =>
[marker.entity, model].filter(Boolean),
);
if (!Number.isFinite(height)) return null;
const carto = Cesium.Cartographic.fromCartesian(
marker.position,
Cesium.Ellipsoid.WGS84,
scratchCarto,
);
return Cesium.Cartesian3.fromRadians(
carto.longitude,
carto.latitude,
height + (model?._gevBellyM || 0),
Cesium.Ellipsoid.WGS84,
scratchGround,
);
}
return {
/** Ids whose model admission is current; the layer enriches these. */
get eligible() {
return eligible;
},
/**
* Per-frame pass: re-derive eligibility at the fleet cadence, then place
* every admitted model and hand each marker's visual to its model only
* once the model is ready and placed.
* @param {Map<string, object>} markers Layer markers (position, courseDeg,
* klass, onGround); `modelOwnsVisual` is written back.
* @param {object} preferences `{ models3d, models3dMode }`.
* @param {number} nowMs
*/
frame(markers, preferences, nowMs) {
if (destroyed) return;
if (!regime(preferences)) {
if (models.size || pending.size) releaseAll();
eligible = new Set();
for (const marker of markers.values()) marker.modelOwnsVisual = false;
return;
}
if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) {
lastEligibilityAt = nowMs;
refreshEligibility(markers, preferences);
}
const occluder = horizonOccluder(viewer.camera);
for (const [id, marker] of markers) {
const model = models.get(id);
marker.modelOwnsVisual = false;
if (!model || !marker.position) continue;
const position = placement(id, marker);
if (!position || !occluder.isPointVisible(marker.position)) {
model.show = false;
continue;
}
scratchHpr.heading = Cesium.Math.toRadians(
(marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG,
);
scratchHpr.pitch = 0;
scratchHpr.roll = 0;
Cesium.Transforms.headingPitchRollToFixedFrame(
position,
scratchHpr,
Cesium.Ellipsoid.WGS84,
undefined,
model.modelMatrix,
);
if (!model.ready) {
model.show = false;
continue;
}
model.show = true;
marker.modelOwnsVisual = true;
}
for (const id of [...models.keys()]) if (!markers.has(id)) release(id);
},
/**
* Drop the model, and any load still in flight, of every aircraft that
* is no longer a marker. The layer calls this whenever markers expire:
* the per-frame pass does not run once the last marker is gone.
* @param {Map<string, object>|Set<string>} live Current marker ids.
*/
retain(live) {
if (destroyed) return;
let dropped = false;
for (const id of [...models.keys(), ...pending.keys()]) {
if (live.has(id)) continue;
release(id);
eligible.delete(id);
dropped = true;
}
if (dropped) eligible = new Set(eligible);
},
/** Drop every model (3D off, layer disabled). */
clear: releaseAll,
/** Number of admitted models (tests and diagnostics). */
get size() {
return models.size;
},
destroy() {
destroyed = true;
releaseAll();
groundSnap?.clear?.();
if (!viewer.isDestroyed?.() && !collection.isDestroyed())
viewer.scene.primitives.remove(collection);
},
};
}
+427
View File
@@ -0,0 +1,427 @@
import * as Cesium from 'cesium';
import {
COURSE_HOLD_SPEED_MPS,
TURN_MIN_SPEED_MPS,
courseBetweenCartesians,
courseSlewCapDps,
estimateTurnRateDps,
lerpAngleDeg,
limitCourseStep,
norm360,
projectGroundArcLatLon,
speedRamp,
staleCoastLimitSeconds,
} from '../../data/motionModel.js';
import {
LOCAL_ADSB_REANCHOR_AFTER,
localAdsbFixIsPlausible,
} from '../../sources/adsbRecords.js';
import {
COURSE_MAX_DPS,
COURSE_SLEW_DT_MAX_SEC,
DR_CORRECTION_MS,
} from '../flights/policy.js';
/** Heard positions kept per aircraft for its trail: 10 minutes, 600 fixes. */
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
export const LOCAL_ADSB_HISTORY_POINTS = 600;
/** A display correction larger than this snaps instead of sliding. */
export const LOCAL_ADSB_SNAP_M = 3_000;
/** Chord course uses the newest fix at least this much older than the anchor. */
const CHORD_BASELINE_MS = 2_000;
/** Turn rate is estimated over this window of heard fixes. */
const TURN_WINDOW_MS = 30_000;
/** Coast at least this long past a fix; longer while messages keep coming. */
const COAST_MINIMUM_SEC = 10;
const COAST_CONTACT_GRACE_SEC = 10;
const COAST_MAXIMUM_SEC = 60;
const KT_TO_MPS = 1852 / 3600;
const EARTH_RADIUS_M = 6_371_008.8;
const DEG = Math.PI / 180;
const _chordFrom = new Cesium.Cartesian3();
const _chordTo = new Cesium.Cartesian3();
function finite(value) {
return Number.isFinite(value) ? value : null;
}
/**
* When a record's telemetry (altitude, speed, track, climb) was last heard:
* its newest message, which velocity-only and altitude-only messages advance
* without a new position.
*/
function telemetryTime(record) {
const messageAt = finite(record?.lastMessageAt);
const positionAt = finite(record?.lastPositionAt);
if (messageAt === null) return positionAt;
return positionAt === null ? messageAt : Math.max(messageAt, positionAt);
}
function chordCourse(from, to) {
Cesium.Cartesian3.fromDegrees(from.lon, from.lat, 0, undefined, _chordFrom);
Cesium.Cartesian3.fromDegrees(to.lon, to.lat, 0, undefined, _chordTo);
return courseBetweenCartesians(_chordFrom, _chordTo);
}
/**
* Real-time display motion for one locally heard aircraft.
*
* Unlike the public flight layers there is no deliberate one-poll display
* delay: local fixes arrive about once a second, so the marker is
* extrapolated FORWARD from the newest fix with the shared motion model
* (constant-rate-turn arc, path-derived course blended over the reported
* track by speed, rate-limited slew) and a new fix is absorbed by a
* correction offset that decays over the same window the tracked public
* flight uses, so the marker never snaps. Every fix also passes the same
* speed check the decoder applies, which covers decoder-feed records too.
*
* Positions are geodetic (degrees, barometric feet); callers add the render
* datum. The heard history doubles as the selected aircraft's trail.
*/
export class LocalAdsbMotion {
constructor() {
/** @type {Array<{at:number, lat:number, lon:number, altitudeFt:number|null,
* trackDeg:number|null, speedMps:number|null}>} */
this.fixes = [];
this.anchor = null;
this.correction = null;
this.courseDeg = null;
this.lastCourseAt = null;
this.lastContactAt = null;
this.rejectStreak = 0;
this.rejectedFixes = 0;
// The newest position evaluated, accepted or refused. Re-reading it (the
// layer re-syncs the same record several times a second) is not a new
// observation and must not count toward a re-anchor.
this.lastCandidate = null;
// When the anchor's telemetry was heard. Telemetry has its own freshness:
// position freshness only decides trail points and re-anchoring.
this.telemetryAt = null;
}
/**
* Take a record's newest position. Returns true when it became the new
* anchor; a repeated, out-of-order or implausible fix returns false.
* @param {object} record Local ADS-B record.
* @param {number} nowMs Current epoch ms (display clock).
* @returns {boolean}
*/
observe(record, nowMs) {
if (Number.isFinite(record?.lastMessageAt))
this.lastContactAt = Math.max(
this.lastContactAt ?? -Infinity,
record.lastMessageAt,
);
const at = finite(record?.lastPositionAt);
const lat = finite(record?.lat);
const lon = finite(record?.lon);
if (at === null || lat === null || lon === null) return false;
const newest = this.fixes.at(-1);
const seen = this.lastCandidate;
// No new position, but velocity and altitude messages arrive between
// position messages: newer telemetry still reaches the anchor.
if (
(newest && at <= newest.at) ||
(seen && at <= seen.at) ||
// A decoder feed re-reads the same fix every poll and its rebased time
// jitters with `seen_pos` rounding: the same position is not a new fix.
(seen && !seen.accepted && lat === seen.lat && lon === seen.lon)
) {
this._refreshTelemetry(record, nowMs);
return false;
}
if (newest && lat === newest.lat && lon === newest.lon) {
// Same coordinates, but altitude and velocity may still be newer (a
// hovering helicopter climbing, an aircraft coming to a stop): update
// the motion telemetry without adding a trail point.
this.lastCandidate = { at, lat, lon, accepted: true };
this.rejectStreak = 0;
this._refreshTelemetry(record, nowMs);
return false;
}
const speedMps = Number.isFinite(record.groundSpeedKt)
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
: (newest?.speedMps ?? null);
const fix = {
at,
lat,
lon,
altitudeFt: finite(record.altitudeFt),
trackDeg: Number.isFinite(record.trackDeg)
? norm360(record.trackDeg)
: null,
speedMps,
};
if (
newest &&
!localAdsbFixIsPlausible(newest, fix, {
// The last known speed stands in while a record carries none.
groundSpeedKt: Number.isFinite(speedMps) ? speedMps / KT_TO_MPS : null,
category: record.category,
})
) {
this.rejectedFixes += 1;
this.rejectStreak += 1;
this.lastCandidate = { at, lat, lon, accepted: false };
if (this.rejectStreak < LOCAL_ADSB_REANCHOR_AFTER) {
// The position is refused, not the record's telemetry.
this._refreshTelemetry(record, nowMs);
return false;
}
// Three refusals in a row: the stream moved on and the old anchor was
// the outlier. Restart the history from the new fix.
this.fixes = [];
this.correction = null;
}
this.rejectStreak = 0;
this.lastCandidate = { at, lat, lon, accepted: true };
const prior = this.anchor;
const before = prior ? this.displayAt(nowMs, { slew: false }) : null;
this.fixes.push(fix);
this._trimHistory(at);
// Position and telemetry are accepted independently: merged receivers
// can deliver a newer position in a record whose last message is OLDER
// than the telemetry the anchor already holds. The trail point keeps the
// record's values as heard; the anchor keeps the newer telemetry.
const recordTelemetryAt = telemetryTime(record);
const keepTelemetry =
prior &&
this.telemetryAt !== null &&
(recordTelemetryAt === null || recordTelemetryAt < this.telemetryAt);
if (keepTelemetry) {
this.anchor = {
...this._anchorFrom(
{
...fix,
altitudeFt: prior.altitudeFt,
speedMps: prior.speedMps,
trackDeg: prior.trackDeg,
},
{ verticalRateFpm: prior.verticalRateFpm },
),
altitudeAt: Number.isFinite(prior.altitudeAt)
? prior.altitudeAt
: prior.at,
};
} else {
this.anchor = this._anchorFrom(fix, record);
this.telemetryAt = recordTelemetryAt;
}
this._absorb(before, nowMs);
return true;
}
/**
* Apply a record's altitude, speed, track and vertical rate to the current
* anchor when they were heard after the anchor's telemetry, whether or not
* its position is new.
*/
_refreshTelemetry(record, nowMs) {
const anchor = this.anchor;
const newest = this.fixes.at(-1);
if (!anchor || !newest) return;
const at = telemetryTime(record);
if (at === null || (this.telemetryAt !== null && at <= this.telemetryAt))
return;
this.telemetryAt = at;
const altitudeFt = finite(record.altitudeFt) ?? anchor.altitudeFt;
const speedMps = Number.isFinite(record.groundSpeedKt)
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
: anchor.speedMps;
const trackDeg = Number.isFinite(record.trackDeg)
? norm360(record.trackDeg)
: anchor.trackDeg;
const verticalRateFpm = finite(record.verticalRateFpm);
if (
altitudeFt === anchor.altitudeFt &&
speedMps === anchor.speedMps &&
trackDeg === anchor.trackDeg &&
verticalRateFpm === anchor.verticalRateFpm
)
return;
const before = this.displayAt(nowMs, { slew: false });
// The trail point keeps its heard position; the anchor takes the newer
// telemetry, with altitude extrapolated from when it was reported.
this.anchor = {
...this._anchorFrom(
{ ...newest, altitudeFt, speedMps, trackDeg },
{ verticalRateFpm },
),
altitudeAt: at,
};
this._absorb(before, nowMs);
}
/** Slide from where the marker was drawn to the new anchor, or snap. */
_absorb(before, nowMs) {
if (before) {
const after = this._raw(nowMs);
const cosLat = Math.cos(after.lat * DEG);
const north = (before.lat - after.lat) * DEG * EARTH_RADIUS_M;
const east = (before.lon - after.lon) * DEG * EARTH_RADIUS_M * cosLat;
const up =
Number.isFinite(before.altitudeFt) && Number.isFinite(after.altitudeFt)
? before.altitudeFt - after.altitudeFt
: 0;
this.correction =
Math.hypot(north, east) > LOCAL_ADSB_SNAP_M
? null
: { east, north, upFt: up, startAt: nowMs };
}
}
_trimHistory(newestAt) {
let drop = 0;
while (
drop < this.fixes.length - 1 &&
(newestAt - this.fixes[drop].at > LOCAL_ADSB_HISTORY_MS ||
this.fixes.length - drop > LOCAL_ADSB_HISTORY_POINTS)
)
drop += 1;
if (drop) this.fixes.splice(0, drop);
}
_anchorFrom(fix, record) {
let chord = null;
for (let index = this.fixes.length - 2; index >= 0; index -= 1) {
if (fix.at - this.fixes[index].at < CHORD_BASELINE_MS) continue;
chord = chordCourse(this.fixes[index], fix);
break;
}
let courseDeg = fix.trackDeg;
if (chord !== null)
courseDeg =
courseDeg === null
? chord
: lerpAngleDeg(courseDeg, chord, speedRamp(fix.speedMps));
const samples = [];
let lastSampleAt = -Infinity;
for (const sample of this.fixes) {
if (fix.at - sample.at > TURN_WINDOW_MS) continue;
if (sample.at - lastSampleAt < CHORD_BASELINE_MS) continue;
lastSampleAt = sample.at;
samples.push({
tSec: sample.at / 1000,
trackDeg: sample.trackDeg,
speedMps: sample.speedMps,
});
}
return {
...fix,
courseDeg,
turnRateDps: estimateTurnRateDps(
samples,
undefined,
undefined,
TURN_MIN_SPEED_MPS,
),
verticalRateFpm: finite(record.verticalRateFpm),
};
}
_coastSeconds(nowMs) {
const anchor = this.anchor;
const limit = staleCoastLimitSeconds({
fixEpochMs: anchor.at,
lastContactEpochMs: this.lastContactAt,
minimumSec: COAST_MINIMUM_SEC,
contactGraceSec: COAST_CONTACT_GRACE_SEC,
maximumSec: COAST_MAXIMUM_SEC,
});
return Math.min(limit, Math.max(0, (nowMs - anchor.at) / 1000));
}
_raw(nowMs) {
const anchor = this.anchor;
const dtSec = this._coastSeconds(nowMs);
const moving =
Number.isFinite(anchor.speedMps) &&
anchor.speedMps >= COURSE_HOLD_SPEED_MPS &&
Number.isFinite(anchor.courseDeg);
const projected = moving
? projectGroundArcLatLon(
anchor.lat,
anchor.lon,
anchor.courseDeg,
anchor.speedMps,
anchor.turnRateDps,
dtSec,
)
: { lat: anchor.lat, lon: anchor.lon };
const altitudeDtSec = Number.isFinite(anchor.altitudeAt)
? Math.min(dtSec, Math.max(0, (nowMs - anchor.altitudeAt) / 1000))
: dtSec;
const altitudeFt =
Number.isFinite(anchor.altitudeFt) &&
Number.isFinite(anchor.verticalRateFpm)
? anchor.altitudeFt + (anchor.verticalRateFpm * altitudeDtSec) / 60
: anchor.altitudeFt;
return {
lat: projected.lat,
lon: projected.lon,
altitudeFt,
courseDeg: moving
? norm360(anchor.courseDeg + anchor.turnRateDps * dtSec)
: null,
speedMps: anchor.speedMps,
};
}
/**
* Where to draw the aircraft now.
* @param {number} nowMs Current epoch ms.
* @param {{slew?: boolean}} [options] `slew: false` reads without advancing
* the rate-limited course.
* @returns {{lat:number, lon:number, altitudeFt:number|null,
* courseDeg:number|null}|null}
*/
displayAt(nowMs, { slew = true } = {}) {
if (!this.anchor) return null;
const raw = this._raw(nowMs);
const correction = this.correction;
if (correction) {
const weight = 1 - (nowMs - correction.startAt) / DR_CORRECTION_MS;
if (weight <= 0) this.correction = null;
else {
const w = Math.min(1, weight);
raw.lat += (correction.north * w) / EARTH_RADIUS_M / DEG;
raw.lon +=
(correction.east * w) /
(EARTH_RADIUS_M * Math.max(1e-6, Math.cos(raw.lat * DEG))) /
DEG;
if (Number.isFinite(raw.altitudeFt))
raw.altitudeFt += correction.upFt * w;
}
}
let courseDeg = this.courseDeg;
// Hover and taxi noise: below the hold speed the course is not updated.
const target =
raw.courseDeg ?? (courseDeg === null ? this.anchor.trackDeg : null);
if (slew && target !== null) {
const dtSec =
this.lastCourseAt === null
? 0
: Math.min(
COURSE_SLEW_DT_MAX_SEC,
Math.max(0, (nowMs - this.lastCourseAt) / 1000),
);
courseDeg = limitCourseStep(
courseDeg,
target,
courseSlewCapDps(raw.speedMps, COURSE_MAX_DPS),
dtSec,
);
this.courseDeg = courseDeg;
this.lastCourseAt = nowMs;
} else if (courseDeg === null && target !== null) {
courseDeg = target;
}
return {
lat: raw.lat,
lon: raw.lon,
altitudeFt: raw.altitudeFt,
courseDeg,
};
}
}
+209
View File
@@ -0,0 +1,209 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
LOCAL_ADSB_HISTORY_MS,
LOCAL_ADSB_HISTORY_POINTS,
LocalAdsbMotion,
} from './motion.js';
function fix(at, lat, overrides = {}) {
return {
icao: 'abc123',
lat,
lon: 0,
altitudeFt: 5_000,
groundSpeedKt: 120,
trackDeg: 0,
verticalRateFpm: 0,
lastPositionAt: at,
lastMessageAt: at,
...overrides,
};
}
// 120 kt north: degrees of latitude per second.
const STEP = (120 * 1852) / 3600 / 111_195;
test('the heard history is bounded to 600 fixes and 10 minutes', () => {
const motion = new LocalAdsbMotion();
for (let second = 0; second < 700; second += 1)
motion.observe(fix(second * 1_000, second * STEP), second * 1_000);
assert.equal(motion.fixes.length, LOCAL_ADSB_HISTORY_POINTS);
assert.equal(motion.fixes[0].at, 100_000);
const sparse = new LocalAdsbMotion();
for (let minute = 0; minute < 15; minute += 1) {
const at = minute * 60_000;
sparse.observe(fix(at, minute * 60 * STEP), at);
}
assert.ok(
sparse.fixes.at(-1).at - sparse.fixes[0].at <= LOCAL_ADSB_HISTORY_MS,
);
assert.equal(sparse.fixes.length, 11);
});
test('repeated and out-of-order fixes do not move the anchor', () => {
const motion = new LocalAdsbMotion();
assert.equal(motion.observe(fix(10_000, 0), 10_000), true);
assert.equal(motion.observe(fix(10_000, 0), 10_500), false);
assert.equal(motion.observe(fix(9_000, 0), 10_500), false);
// A feed re-reading the same fix with a jittered, later time.
assert.equal(motion.observe(fix(10_080, 0), 11_000), false);
assert.equal(motion.fixes.length, 1);
});
test('coasting stops 10 s after the last message', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
const coasted = motion.displayAt(30_000).lat;
assert.ok(Math.abs(coasted - 10 * STEP) < 1e-5, `${coasted}`);
});
test('three refused fixes in a row restart the track from the new stream', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
for (let index = 1; index <= 2; index += 1)
assert.equal(
motion.observe(fix(index * 1_000, 1 + index * STEP), index * 1_000),
false,
);
assert.equal(motion.rejectedFixes, 2);
assert.equal(motion.observe(fix(3_000, 1 + 3 * STEP), 3_000), true);
assert.equal(motion.rejectedFixes, 3);
assert.equal(
motion.fixes.length,
1,
'the history restarts at the new stream',
);
assert.ok(Math.abs(motion.displayAt(3_000).lat - (1 + 3 * STEP)) < 1e-9);
});
test('the last known speed bounds a fix whose record carries none', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
// 1.2 nm in 10 s (432 kt): under the 1,000 kt unknown-speed cap but far
// beyond the 120 kt the aircraft last reported.
assert.equal(
motion.observe(fix(10_000, 1.2 / 60, { groundSpeedKt: null }), 10_000),
false,
);
assert.equal(motion.rejectedFixes, 1);
});
test('re-observing one refused fix never re-anchors on it', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0), 0);
const outlier = fix(1_000, 1);
// The layer re-syncs the same record several times a second.
for (let sync = 0; sync < 6; sync += 1)
assert.equal(motion.observe(outlier, 1_000 + sync * 250), false);
assert.equal(motion.rejectedFixes, 1, 'one fix is refused once');
assert.equal(motion.rejectStreak, 1);
assert.ok(motion.displayAt(2_000).lat < 0.01, 'still on the old track');
// A jittered re-read of the same refused position is the same fix too.
assert.equal(motion.observe(fix(1_080, 1), 2_500), false);
assert.equal(motion.rejectStreak, 1);
});
test('an unchanged position still delivers newer altitude and velocity', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 120 }), 0);
// A hovering helicopter: same coordinates, climbing, now stationary.
motion.observe(
fix(5_000, 0, {
altitudeFt: 1_500,
groundSpeedKt: 0,
verticalRateFpm: 0,
trackDeg: 90,
}),
5_000,
);
assert.equal(motion.fixes.length, 1, 'no duplicate trail point');
assert.equal(motion.anchor.speedMps, 0);
assert.equal(motion.anchor.trackDeg, 90);
// Past the correction window the display settles on the new telemetry.
const display = motion.displayAt(20_000);
assert.ok(
Math.abs(display.altitudeFt - 1_500) < 1e-6,
`${display.altitudeFt}`,
);
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
});
test('a delayed position never rolls back newer telemetry', () => {
const motion = new LocalAdsbMotion();
const step100 = (100 * 1852) / 3600 / 111_195;
assert.equal(
motion.observe(
fix(10_000, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }),
10_000,
),
true,
);
// Velocity and altitude heard at 20 s, no new position.
motion.observe(
fix(10_000, 0, {
altitudeFt: 1_500,
groundSpeedKt: 0,
lastMessageAt: 20_000,
}),
20_000,
);
assert.equal(motion.anchor.speedMps, 0);
assert.equal(motion.telemetryAt, 20_000);
// Another receiver's record: a newer position (15 s) whose telemetry is
// older than what the anchor already holds.
const delayed = fix(15_000, 5 * step100, {
altitudeFt: 1_000,
groundSpeedKt: 100,
});
assert.equal(motion.observe(delayed, 21_000), true, 'the position is new');
assert.equal(motion.fixes.length, 2);
assert.equal(motion.anchor.lat, 5 * step100, 'the anchor moves');
assert.equal(motion.anchor.speedMps, 0, 'telemetry stays 0 kt');
assert.equal(motion.anchor.altitudeFt, 1_500, 'telemetry stays 1,500 ft');
assert.equal(motion.telemetryAt, 20_000, 'telemetryAt never moves back');
assert.equal(motion.fixes.at(-1).altitudeFt, 1_000, 'trail point as heard');
const display = motion.displayAt(40_000);
assert.ok(
Math.abs(display.altitudeFt - 1_500) < 1e-6,
`${display.altitudeFt}`,
);
assert.ok(
Math.abs(display.lat - 5 * step100) < 1e-9,
'a stopped aircraft does not coast',
);
});
test('a velocity/altitude-only update with the same position time still refreshes telemetry', () => {
const motion = new LocalAdsbMotion();
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), 0);
// No new position message: only velocity and altitude are newer.
const newer = fix(0, 0, {
altitudeFt: 1_500,
groundSpeedKt: 0,
trackDeg: 90,
lastMessageAt: 5_000,
});
assert.equal(motion.observe(newer, 5_000), false, 'no new trail point');
assert.equal(motion.fixes.length, 1);
assert.equal(motion.anchor.speedMps, 0);
assert.equal(motion.anchor.altitudeFt, 1_500);
assert.equal(motion.anchor.trackDeg, 90);
// Re-reading that record is not newer telemetry, and an older message
// never rolls it back.
motion.observe(newer, 5_500);
motion.observe(
fix(0, 0, { altitudeFt: 900, groundSpeedKt: 300, lastMessageAt: 4_000 }),
6_000,
);
assert.equal(motion.anchor.altitudeFt, 1_500);
assert.equal(motion.anchor.speedMps, 0);
const display = motion.displayAt(20_000);
assert.ok(
Math.abs(display.altitudeFt - 1_500) < 1e-6,
`${display.altitudeFt}`,
);
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
});
+21
View File
@@ -0,0 +1,21 @@
export const LAYER_ID = 'local-adsb';
export const LAYER_NAME = 'Local ADS-B';
export const LAYER_SOURCE = 'RTL-SDR · WebUSB';
/** Distinct from the public Flights palette so both can be drawn together. */
export const LOCAL_ADSB_COLOR = '#ff4fd8';
/** Ring drawn around 978 MHz UAT aircraft: same magenta family, lighter. */
export const LOCAL_ADSB_UAT_RING_COLOR = '#ffb3ef';
export const ENTITY_PREFIX = `${LAYER_ID}:`;
/** Re-evaluate marker freshness and the card's position age this often. */
export const LOCAL_ADSB_TICK_MS = 1_000;
/** Coalesce receiver bursts into at most one scene sync per interval. */
export const LOCAL_ADSB_SYNC_MS = 200;
export const HEARD_BY_RECEIVER = 'Heard by your receiver';
export const BAND_LABELS = Object.freeze({
1090: '1090 MHz',
978: '978 MHz UAT',
});
export const SOURCE_LABELS = Object.freeze({
webusb: 'browser SDR',
feed: 'decoder feed',
});
+141
View File
@@ -0,0 +1,141 @@
import { localReceiverFeedName } from './feedNames.js';
import { LAYER_SOURCE } from './policy.js';
/**
* Layers-panel status for Local ADS-B, from its two inputs: the browser
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
*/
export const FEED_SOURCE = 'Decoder feeds';
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
function plural(count, one, many) {
return count === 1 ? one : many;
}
/**
* Describe the feeds that are not live, grouped by status, e.g.
* "feed 978 unreachable" or "feeds 1090, 978 stale".
* @param {object[]} feeds Feed statuses from the route.
* @returns {string}
*/
export function describeFeedProblems(feeds) {
const byStatus = new Map();
for (const feed of feeds) {
if (feed?.status === 'live') continue;
const status = feed?.status || 'unreachable';
if (!byStatus.has(status)) byStatus.set(status, new Set());
byStatus.get(status).add(localReceiverFeedName(feed, feeds));
}
return [...byStatus]
.map(([status, names]) => {
const list = [...names];
return `${plural(list.length, 'feed', 'feeds')} ${list.join(', ')} ${status}`;
})
.join(' · ');
}
/** Whether the server reported at least one configured feed. */
export function feedsConfigured(feedState) {
return Boolean(
feedState?.configured === true &&
Array.isArray(feedState.feeds) &&
feedState.feeds.length,
);
}
function webUsbStatus(receiver, heard, feedState) {
if (receiver.status === 'error')
return { status: 'error', error: receiver.message };
if (receiver.status === 'connecting' || receiver.status === 'tuning')
return { loading: true, loadingLabel: 'opening receiver' };
if (
feedState?.polling &&
feedState.configured === null &&
!receiver.connected
)
return { loading: true, loadingLabel: 'checking decoder feeds' };
if (!receiver.webUsbSupported)
return {
status: 'idle',
statusMessage: 'WebUSB needs desktop Chrome or Edge',
};
if (!receiver.connected)
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
if (receiver.mode !== 'adsb')
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
const rate = Number.isFinite(receiver.messagesPerSecond)
? `${receiver.messagesPerSecond} msg/s`
: 'listening';
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
}
/**
* Status fields of `getStats()` for the Local ADS-B row.
*
* Without configured feeds this is exactly the WebUSB status. With feeds:
* "2 feeds live · 14 heard" when every feed is live. While any input is
* producing (the browser receiver streaming, or a feed live) the row stays
* nominal and names the other feeds as a trailing note, e.g. "3 heard · USB
* 5.8 msg/s · feed 1090 stale": a decoder stopped so the browser can take the
* dongle is not a fault. With nothing producing, every readable feed stale is
* STALE and anything else is an error.
* @param {object} options
* @param {object} options.receiver WebUSB receiver snapshot.
* @param {object|null} options.feedState Feed poller snapshot.
* @param {number} options.heard Merged aircraft heard in the last 60 s.
* @returns {object} status / statusMessage / loading / loadingLabel / error /
* stale / source.
*/
export function localAdsbStatus({ receiver, feedState, heard }) {
if (!feedsConfigured(feedState))
return {
source: LAYER_SOURCE,
...webUsbStatus(receiver, heard, feedState),
};
const usbActive = Boolean(receiver.connected && receiver.mode === 'adsb');
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
const live = feedState.feeds.filter((feed) => feed.status === 'live');
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
if (usbActive && receiver.status === 'error') problems.push('USB error');
const rate =
usbActive &&
receiver.status !== 'error' &&
Number.isFinite(receiver.messagesPerSecond)
? ` · USB ${receiver.messagesPerSecond} msg/s`
: '';
const heardText = `${heard} heard`;
if (!problems.length) {
return {
source,
status: 'streaming',
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
};
}
const problemText = problems.join(' · ');
const usbProducing = usbActive && receiver.status !== 'error';
if (!live.length && !usbProducing) {
// Decoders that stopped but still serve their last aircraft.json are
// stale, not down: only an unreachable or unusable feed is an error.
const readable = feedState.feeds.filter(
(feed) => feed.status !== 'invalid',
);
if (readable.length && readable.every((feed) => feed.status === 'stale'))
return {
source,
status: 'stale',
stale: true,
statusMessage: `${problemText} · ${heardText}`,
};
return {
source,
status: 'error',
error: heard > 0 ? `${problemText} · ${heardText}` : problemText,
};
}
return {
source,
status: 'streaming',
loadingLabel: `${heardText}${rate} · ${problemText}`,
};
}
+11 -22
View File
@@ -8,6 +8,8 @@ import {
trailAnchorForModel,
} from '../../data/modelVisualAnchor.js';
import * as Cesium from 'cesium';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -151,7 +153,7 @@ export function createRendering({
const isCockpitNear =
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = bb.color?.alpha ?? 1;
const freshnessAlpha = cyberSonarBaseAlpha(bb);
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
@@ -171,7 +173,7 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1);
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
@@ -892,25 +894,11 @@ export function createRendering({
);
flightState._lastModelCapWarnMs = nowMs;
}
modelEligible = new Set();
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && flightState._models.has(icao)) modelEligible.add(icao);
} // 1. KEEP on-screen
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
} // 2. ADD on-screen
for (const [icao, , inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && flightState._models.has(icao)) modelEligible.add(icao);
} // 3. KEEP off-screen (can't starve visible)
for (const [icao, d2, inF] of cand) {
if (modelEligible.size >= cap) break;
if (!inF && d2 <= addDistSq && !modelEligible.has(icao))
modelEligible.add(icao);
} // 4. ADD off-screen leftover
modelEligible = selectModelEligible(cand, {
cap,
addDistSq,
isModeled: (icao) => flightState._models.has(icao),
});
const toRelease = [];
for (const icao of flightState._models.keys()) {
if (icao !== flightState._trackedIcao && !modelEligible.has(icao))
@@ -977,6 +965,7 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor = MIL_ICON_COLOR;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
@@ -984,7 +973,7 @@ export function createRendering({
? 1
: _militaryBillboardScale(icao24),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseColor: MIL_ICON_COLOR,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
+2
View File
@@ -227,6 +227,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+157
View File
@@ -0,0 +1,157 @@
/** Card model for one selected fire-perimeter incident. Pure — no Cesium types. */
export const PERIMETER_OVERLAY_SOURCE_ID = 'fire-perimeters';
/** CSS accent for a perimeter card by containment progress (mirrors the fill). */
export function containmentAccent(containedPct) {
if (!Number.isFinite(containedPct) || containedPct <= 0) return '#ff3b30';
if (containedPct < 50) return '#ff7a00';
if (containedPct < 100) return '#ffb300';
return '#8bc34a';
}
/** Shoelace area (degree², sign dropped) — relative sizes only. */
function ringArea(ring) {
let sum = 0;
for (let i = 0; i < ring.length - 1; i++) {
const [x1, y1] = ring[i];
const [x2, y2] = ring[i + 1];
sum += x1 * y2 - x2 * y1;
}
return Math.abs(sum / 2);
}
/**
* Anchor point for the incident card: the centroid of the largest polygon's
* outer ring. Degrees, so callers own the Cartesian conversion.
* @param {Array<Array<Array<[number, number]>>>} polygons - Normalized rows' polygons.
* @returns {{lon: number, lat: number}}
*/
export function perimeterAnchorDegrees(polygons) {
let best = null;
let bestArea = -1;
for (const rings of polygons) {
const outer = rings[0];
const area = ringArea(outer);
if (area > bestArea) {
bestArea = area;
best = outer;
}
}
// Vertex mean over the closed ring (first == last point dropped): perimeters
// are compact enough that this stays inside or near the polygon.
let lon = 0;
let lat = 0;
const count = best.length - 1;
for (let i = 0; i < count; i++) {
lon += best[i][0];
lat += best[i][1];
}
return { lon: lon / count, lat: lat / count };
}
/** Compact USD, e.g. $85K / $4.2M / $1.3B. Null under $1,000. */
function formatCost(dollars) {
if (!Number.isFinite(dollars) || dollars < 1000) return null;
const units = [
[1e3, 'K'],
[1e6, 'M'],
[1e9, 'B'],
];
let index = 0;
for (let i = units.length - 1; i >= 0; i--) {
if (dollars >= units[i][0]) {
index = i;
break;
}
}
// 999,500 must promote to $1M, never render as $1000K.
if (dollars >= units[index][0] * 999.5 && index < units.length - 1) index++;
const value = dollars / units[index][0];
return `$${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10}${units[index][1]}`;
}
/** WFIGS complex names arrive ALL-CAPS; render them in title case. */
function titleCase(name) {
return String(name)
.toLowerCase()
.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
const hours = Math.floor(deltaMs / 3600000);
if (hours < 1) return `${Math.max(1, Math.floor(deltaMs / 60000))}m`;
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* Build the overlay-host entry for one selected incident. The caller supplies
* `position` (Cartesian) separately — this model stays JSON-safe for tests.
* @param {Object} row - Normalized perimeter row (see records.js).
* @param {number} nowMs - Current epoch milliseconds.
* @param {{link: ?string}} [extras] - Optional InciWeb page for this incident.
* @returns {Object} World-overlay entry without `position`.
*/
export function buildIncidentCard(row, nowMs, { link = null } = {}) {
const facts = [];
if (Number.isFinite(row.acres))
facts.push(`${Math.round(row.acres).toLocaleString('en-US')} ac`);
facts.push(
Number.isFinite(row.containedPct)
? `${Math.round(row.containedPct)}% contained`
: 'containment unknown',
);
if (row.state) facts.push(row.state);
else if (row.category) facts.push(row.category);
const situation = [];
if (row.cause) situation.push(`${row.cause} cause`);
if (row.behavior) situation.push(row.behavior);
if (row.complexity) situation.push(row.complexity);
const response = [];
if (Number.isFinite(row.personnel))
response.push(
`${Math.round(row.personnel).toLocaleString('en-US')} personnel`,
);
if (row.county) response.push(`${row.county} County`);
if (Number.isFinite(row.costToDate)) {
const cost = formatCost(row.costToDate);
if (cost) response.push(`${cost} to date`);
}
const ages = [];
const discovered = formatAge(nowMs - row.discoveredTime);
if (row.discoveredTime != null && discovered)
ages.push(`discovered ${discovered} ago`);
const updated = formatAge(nowMs - row.updatedTime);
if (row.updatedTime != null && updated) ages.push(`updated ${updated} ago`);
const details = [facts.join(' · ')];
if (row.complexName) details.push(`part of ${titleCase(row.complexName)}`);
if (situation.length) details.push(situation.join(' · '));
if (response.length) details.push(response.join(' · '));
if (ages.length) details.push(ages.join(' · '));
if (link) details.push('InciWeb ↗ · click card to open');
const title = `FIRE · ${row.name || 'Unnamed incident'}`;
return {
id: `fire-perimeter-card:${row.stableId}`,
// `selected` picks the paint lane and collision protection; an explicit
// paintLane would override it in the host's lane resolver.
selected: true,
// Only a linked card is clickable — the overlay host registers hit
// rectangles solely for interactive entries.
interactive: Boolean(link),
...(link ? { accessibilityLabel: `Open ${title} on InciWeb` } : undefined),
title,
details,
accent: containmentAccent(row.containedPct),
priority: Number.MAX_SAFE_INTEGER,
gapPx: 15,
verticalOnly: true,
placement: 'above',
};
}
+237
View File
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { perimeterAnchorDegrees, buildIncidentCard } from './cards.js';
const square = [
[-108.2, 35.0],
[-108.0, 35.0],
[-108.0, 35.2],
[-108.2, 35.2],
[-108.2, 35.0],
];
const tiny = [
[-107.01, 34.0],
[-107.0, 34.0],
[-107.0, 34.01],
[-107.01, 34.0],
];
test('the card anchors at the centroid of the largest polygon', () => {
const anchor = perimeterAnchorDegrees([[tiny], [square]]);
assert.ok(Math.abs(anchor.lon - -108.1) < 1e-9);
assert.ok(Math.abs(anchor.lat - 35.1) < 1e-9);
});
test('a full incident row renders name, size, containment, and ages', () => {
const now = 1758000000000;
const card = buildIncidentCard(
{
stableId: '2026-NMGNF-000123',
name: 'Frijoles',
acres: 15956.4,
containedPct: 74,
state: 'US-NM',
category: 'WF',
discoveredTime: now - 36 * 3600000,
updatedTime: now - 2 * 3600000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
},
now,
);
assert.equal(card.id, 'fire-perimeter-card:2026-NMGNF-000123');
assert.equal(card.title, 'FIRE · Frijoles');
assert.deepEqual(card.details, [
'15,956 ac · 74% contained · US-NM',
'Natural cause · Active · Type 3 Incident',
'380 personnel · Sandoval County · $4.2M to date',
'discovered 1d ago · updated 2h ago',
]);
assert.equal(card.selected, true);
assert.equal(typeof card.accent, 'string');
});
test('missing attributes degrade to available facts instead of placeholders', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: null,
acres: null,
containedPct: null,
state: null,
category: 'RX',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
);
assert.equal(card.title, 'FIRE · Unnamed incident');
assert.deepEqual(card.details, ['containment unknown · RX']);
});
test('a known InciWeb page adds a link line to the card', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Timber',
acres: 25426,
containedPct: 44,
state: 'US-CA',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
{
link: 'https://inciweb.wildfire.gov/incident-information/calpf-timber-fire',
},
);
assert.equal(card.details.at(-1), 'InciWeb ↗ · click card to open');
assert.equal(card.interactive, true);
assert.match(card.accessibilityLabel, /InciWeb/);
});
test('the selected card claims the selected paint lane, not the ambient lane', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Any',
acres: 1,
containedPct: 1,
state: 'US-NM',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: null,
},
1758000000000,
);
assert.equal(card.selected, true);
// An explicit paintLane would override `selected` in the overlay host's
// lane resolver and push the card under other selected cards.
assert.equal('paintLane' in card, false);
assert.equal('collisionGroup' in card, false);
});
test('costs near a unit boundary promote to the larger unit', () => {
const base = {
stableId: 'x',
name: 'Any',
acres: null,
containedPct: null,
state: null,
category: null,
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
complexity: null,
complexName: null,
};
const costLine = (costToDate) =>
buildIncidentCard({ ...base, costToDate }, 0).details.find((line) =>
line.includes('to date'),
);
assert.equal(costLine(999500), '$1M to date');
assert.equal(costLine(999499), '$999K to date');
assert.equal(costLine(999600000), '$1B to date');
assert.equal(costLine(4200000), '$4.2M to date');
});
test('a complex member names its complex on the card', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Crosswhite',
acres: 342923,
containedPct: 99,
state: 'US-OR',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: 'ROWE CREEK COMPLEX',
},
1758000000000,
);
assert.ok(card.details.includes('part of Rowe Creek Complex'));
});
test('a card without a link is not interactive', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Crosswhite',
acres: 1,
containedPct: 1,
state: 'US-OR',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
},
1758000000000,
);
assert.equal(card.interactive, false);
});
test('partial incident details render only the facts that exist', () => {
const card = buildIncidentCard(
{
stableId: 'x',
name: 'Whiskey',
acres: 542,
containedPct: 48,
state: 'US-NM',
category: 'WF',
discoveredTime: null,
updatedTime: null,
cause: 'Human',
behavior: null,
personnel: null,
county: 'Catron',
costToDate: 85000,
complexity: null,
},
1758000000000,
);
assert.deepEqual(card.details, [
'542 ac · 48% contained · US-NM',
'Human cause',
'Catron County · $85K to date',
]);
});
+173
View File
@@ -0,0 +1,173 @@
/** InciWeb incident catalog: full-index fetch and WFIGS-name matching. */
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/**
* Normalize an incident title for matching: lowercase, collapsed whitespace,
* a leading year dropped ('2026 Coleman Creek'), a trailing 'Fire' dropped
* ('Coyote Fire') — InciWeb titles carry both decorations inconsistently and
* WFIGS names carry neither.
*/
function normalizeName(name) {
return String(name)
.toLowerCase()
.trim()
.replace(/\s+/g, ' ')
.replace(/^(19|20)\d{2} /, '')
.replace(/ fire$/, '');
}
/**
* Resolve one WFIGS incident against the publication catalog.
* Only rows whose normalized title equals the incident's normalized name
* count. Ambiguity resolves by the tau dispatch unit's state prefix, then
* by newest incident id (same-state duplicates are usually reburns of the
* same name). The `/node/{id}` link 301s to the canonical page, so no slug
* construction is needed.
* @param {?Array} publications - Publication catalog rows.
* @param {{name: ?string, state: ?string}} incident - WFIGS row facts.
* @returns {?string} InciWeb node URL.
*/
export function resolveInciwebNodeLink(publications, { name, state }) {
if (!Array.isArray(publications) || !name) return null;
const wanted = normalizeName(name);
let candidates = publications.filter(
(row) =>
typeof row?.incident_title === 'string' &&
/^\d+$/.test(String(row?.incident_id ?? '')) &&
normalizeName(row.incident_title) === wanted,
);
// The catalog is all-time and nationwide, so the dispatch unit's state
// must corroborate every match — a globally unique title is NOT evidence
// it's the same fire (live data shows same-name incidents across states).
const statePrefix =
typeof state === 'string' && /^US-[A-Za-z]{2}$/.test(state)
? state.slice(3).toLowerCase()
: null;
if (statePrefix) {
candidates = candidates.filter(
(row) =>
typeof row.tau === 'string' &&
row.tau.slice(0, 2).toLowerCase() === statePrefix,
);
} else if (candidates.length > 1) {
return null;
}
if (candidates.length > 1) {
// Same-state duplicates are usually reburns of the same name — newest
// wins here, and the publication-currency check guards the final link.
candidates = [
candidates.reduce((a, b) =>
Number(a.incident_id) >= Number(b.incident_id) ? a : b,
),
];
}
return candidates.length === 1
? `https://inciweb.wildfire.gov/node/${candidates[0].incident_id}`
: null;
}
/** The publication id inside a node link produced above, or null. */
export function inciwebNodeId(link) {
const match = /^https:\/\/inciweb\.wildfire\.gov\/node\/(\d+)$/.exec(
String(link ?? ''),
);
return match ? match[1] : null;
}
// A fire's InciWeb page is created around the incident's start; a match
// whose page predates discovery by more than this is an archived
// same-name incident from a earlier season.
const CREATED_BEFORE_DISCOVERY_MS = 60 * 24 * 3600000;
// Without a discovery date, require recent editorial activity on the page.
const CHANGED_WITHIN_MS = 180 * 24 * 3600000;
/**
* Whether a publication plausibly describes the given current incident.
* Fails closed on unusable timestamps.
* @param {{createdMs: ?number, changedMs: ?number}} publication
* @param {{discoveredTime: ?number, nowMs?: number}} incident
* @returns {boolean}
*/
export function isCurrentPublication(
{ createdMs, changedMs },
{ discoveredTime, nowMs = Date.now() },
) {
if (Number.isFinite(discoveredTime) && Number.isFinite(createdMs)) {
return createdMs >= discoveredTime - CREATED_BEFORE_DISCOVERY_MS;
}
if (Number.isFinite(changedMs)) {
return nowMs - changedMs <= CHANGED_WITHIN_MS;
}
return false;
}
/** Fetch one publication's created/changed timestamps for currency checks. */
export function createInciwebPublicationSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getPublication(id, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/fire-perimeters/inciweb/publication/${encodeURIComponent(id)}`,
{ signal },
);
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
512 * 1024,
signal,
);
signal?.throwIfAborted();
return {
createdMs: Number.isFinite(payload?.createdMs)
? payload.createdMs
: null,
changedMs: Number.isFinite(payload?.changedMs)
? payload.changedMs
: null,
};
},
};
}
/**
* Resolve one WFIGS incident to its InciWeb page, or null.
* The incident's own name is tried first; a member of a complex whose own
* name has no page falls back to the complex's page (InciWeb tracks the
* managing complex, not each member fire). Anything ambiguous yields null
* rather than a wrong page.
* @param {?Array} publications - Publication catalog rows.
* @param {{name: ?string, state: ?string, complexName: ?string}} incident
* - WFIGS row facts.
* @returns {?string} InciWeb URL.
*/
export function findInciwebLink(publications, { name, state, complexName }) {
return (
resolveInciwebNodeLink(publications, { name, state }) ??
resolveInciwebNodeLink(publications, { name: complexName, state })
);
}
/** Fetch the full InciWeb incident catalog. */
export function createInciwebIndexSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getIndex({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters/inciweb/index', {
signal,
});
if (!response.ok) throw new Error(`InciWeb HTTP ${response.status}`);
const rows = await readResponseJsonCapped(
response,
4 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
return Array.isArray(rows) ? rows : [];
},
};
}
+334
View File
@@ -0,0 +1,334 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
findInciwebLink,
resolveInciwebNodeLink,
createInciwebIndexSource,
createInciwebPublicationSource,
isCurrentPublication,
inciwebNodeId,
} from './inciweb.js';
const catalog = [
{
incident_id: '322812',
tau: 'NMSNF Gobernador Pile Burn Coyote Ranger District',
incident_title: 'Gobernador Pile Burn - Coyote Ranger District',
},
{
incident_id: '327504',
tau: 'SDBKF Coyote Flats Fire',
incident_title: 'Coyote Flats Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '329195',
tau: 'ORBUD Second Flat',
incident_title: 'Second Flat',
},
{
incident_id: '329273',
tau: 'ORBUD 2026 Coleman Creek',
incident_title: '2026 Coleman Creek',
},
{
incident_id: '328923',
tau: 'ORPRD Rowe Creek Complex',
incident_title: 'Rowe Creek Complex',
},
{
incident_id: '291765',
tau: 'CALPF Timber Fire',
incident_title: 'Timber Fire',
},
];
test('incidents resolve by exact normalized title to a node link', () => {
// Title formats vary: with/without a 'Fire' suffix, with/without a year
// prefix ('2026 Coleman Creek'). All normalize to the WFIGS name.
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Second Flat', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/329195',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Coleman Creek', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/329273',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Timber', state: 'US-CA' }),
'https://inciweb.wildfire.gov/node/291765',
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Coyote', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/328922',
);
// Substring-style near-misses never match.
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Gobernador', state: 'US-NM' }),
null,
);
assert.equal(
resolveInciwebNodeLink(catalog, { name: 'Nope', state: null }),
null,
);
assert.equal(
resolveInciwebNodeLink(null, { name: 'Coyote', state: 'US-OR' }),
null,
);
});
test('a unique candidate in the wrong state is rejected, not linked', () => {
// The catalog is all-time and nationwide: a current Arizona "Willow"
// must not link to the only "Willow Fire" on file when it's a Montana
// incident.
const willow = [
{
incident_id: '300001',
tau: 'MTFNF Willow Fire',
incident_title: 'Willow Fire',
},
];
assert.equal(
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-AZ' }),
null,
);
assert.equal(
resolveInciwebNodeLink(willow, { name: 'Willow', state: 'US-MT' }),
'https://inciweb.wildfire.gov/node/300001',
);
});
test('ambiguous titles disambiguate by state, then newest id', () => {
const twoCoyotes = [
{
incident_id: '100',
tau: 'AZASF Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
];
assert.equal(
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: 'US-AZ' }),
'https://inciweb.wildfire.gov/node/100',
);
assert.equal(
resolveInciwebNodeLink(twoCoyotes, { name: 'Coyote', state: null }),
null,
);
const twoSameState = [
{
incident_id: '100',
tau: 'ORXXX Coyote Fire',
incident_title: 'Coyote Fire',
},
{
incident_id: '328922',
tau: 'OR95S Coyote Fire',
incident_title: 'Coyote Fire',
},
];
// Same-state duplicates are usually reburns of the same name — newest wins.
assert.equal(
resolveInciwebNodeLink(twoSameState, { name: 'Coyote', state: 'US-OR' }),
'https://inciweb.wildfire.gov/node/328922',
);
});
test('a complex member falls back to its complex page', () => {
assert.equal(
findInciwebLink(catalog, {
name: 'Crosswhite',
state: 'US-OR',
complexName: 'ROWE CREEK COMPLEX',
}),
'https://inciweb.wildfire.gov/node/328923',
);
// A fire with its own page keeps it even when it belongs to a complex.
assert.equal(
findInciwebLink(catalog, {
name: 'Timber',
state: 'US-CA',
complexName: 'SOME COMPLEX',
}),
'https://inciweb.wildfire.gov/node/291765',
);
assert.equal(
findInciwebLink(catalog, {
name: 'Nope',
state: 'US-NM',
complexName: null,
}),
null,
);
});
test('a node link exposes its publication id', () => {
assert.equal(
inciwebNodeId('https://inciweb.wildfire.gov/node/329195'),
'329195',
);
assert.equal(inciwebNodeId('https://example.com/other'), null);
assert.equal(inciwebNodeId(null), null);
});
test('publication currency accepts pages created around or after discovery', () => {
const day = 24 * 3600000;
const discovery = 1785000000000;
// Created shortly before discovery (page opened as the fire started).
assert.equal(
isCurrentPublication(
{ createdMs: discovery - 10 * day, changedMs: discovery + 30 * day },
{ discoveredTime: discovery },
),
true,
);
// Created years before discovery: an archived same-name incident.
assert.equal(
isCurrentPublication(
{ createdMs: discovery - 900 * day, changedMs: discovery - 800 * day },
{ discoveredTime: discovery },
),
false,
);
// No discovery date: fall back to recent activity on the page.
const now = 1787600000000;
assert.equal(
isCurrentPublication(
{ createdMs: now - 900 * day, changedMs: now - 20 * day },
{ discoveredTime: null, nowMs: now },
),
true,
);
assert.equal(
isCurrentPublication(
{ createdMs: now - 900 * day, changedMs: now - 400 * day },
{ discoveredTime: null, nowMs: now },
),
false,
);
// Unusable timestamps fail closed.
assert.equal(
isCurrentPublication(
{ createdMs: null, changedMs: null },
{ discoveredTime: discovery },
),
false,
);
});
test('the publication source fetches timestamps and honors cancellation', async () => {
let requested;
const source = createInciwebPublicationSource({
fetchImpl: async (url) => {
requested = String(url);
return {
ok: true,
headers: new Headers(),
text: async () =>
JSON.stringify({
createdMs: 1785025540000,
changedMs: 1787589503000,
}),
};
},
});
const publication = await source.getPublication('329195');
assert.equal(requested, '/api/fire-perimeters/inciweb/publication/329195');
assert.deepEqual(publication, {
createdMs: 1785025540000,
changedMs: 1787589503000,
});
const malformed = createInciwebPublicationSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => JSON.stringify({ nope: 1 }),
}),
});
assert.deepEqual(await malformed.getPublication('1'), {
createdMs: null,
changedMs: null,
});
const failing = createInciwebPublicationSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(failing.getPublication('1'), /InciWeb HTTP 503/);
const abort = new AbortController();
const cancelled = createInciwebPublicationSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify({});
},
}),
});
await assert.rejects(
cancelled.getPublication('1', { signal: abort.signal }),
{
name: 'AbortError',
},
);
});
test('the index source requests the same-origin full catalog', async () => {
let request;
const source = createInciwebIndexSource({
fetchImpl: async (url, options) => {
request = { url: String(url), options };
return {
ok: true,
headers: new Headers(),
text: async () => JSON.stringify(catalog),
};
},
});
const rows = await source.getIndex();
assert.equal(rows.length, catalog.length);
assert.equal(request.url, '/api/fire-perimeters/inciweb/index');
assert.equal(request.options.method, undefined);
assert.equal(request.options.body, undefined);
});
test('the index source rejects failures and honors cancellation', async () => {
const failing = createInciwebIndexSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(failing.getIndex(), /InciWeb HTTP 503/);
const malformed = createInciwebIndexSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => JSON.stringify({ nope: 1 }),
}),
});
assert.deepEqual(await malformed.getIndex(), []);
const abort = new AbortController();
const cancelled = createInciwebIndexSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify(catalog);
},
}),
});
await assert.rejects(cancelled.getIndex({ signal: abort.signal }), {
name: 'AbortError',
});
});
+448
View File
@@ -0,0 +1,448 @@
import * as Cesium from 'cesium';
import {
PERIMETER_OVERLAY_SOURCE_ID,
perimeterAnchorDegrees,
buildIncidentCard,
containmentAccent,
} from './cards.js';
import {
findInciwebLink,
inciwebNodeId,
isCurrentPublication,
} from './inciweb.js';
export { normalizeFirePerimeterSnapshot } from './records.js';
export { createWfigsPerimeterSource } from './source.js';
export * from './cards.js';
export * from './inciweb.js';
/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */
export function containmentColor(containedPct) {
return Cesium.Color.fromCssColorString(containmentAccent(containedPct));
}
const ringPositions = (ring) =>
ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat));
const PICK_PREFIX = 'fire-perimeter:';
const CARD_HOST_OPTIONS = Object.freeze({
cohortLimit: 1,
collisionCapacity: 1,
moving: false,
});
// The InciWeb catalog changes on the order of days; refreshing it on the
// 5-minute perimeter cadence would hammer a public .gov endpoint for ~200 KB
// of near-identical bytes.
const INCIWEB_INDEX_TTL_MS = 6 * 3600000;
/** Own one fire-perimeter display, its refresh lifecycle, and click selection. */
export function createFirePerimetersLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
inciwebSource = null,
inciwebPublications = null,
openExternal = null,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Fire perimeters require a snapshot source');
let _viewer = null;
let _request = null;
let _linkRequest = null;
let _snapshotSignature = null;
let _dataSource = null;
let _count = 0;
let _lastUpdate = null;
let _lastError = null;
let _enabled = false;
let _clickHandler = null;
let _selectedId = null;
let _selectedLink = null;
let _selectedCardId = null;
let _inciwebIndex = [];
let _indexFetchedAt = 0;
let _indexFetchInFlight = false;
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
// Link → currency verdict from the publication check; unknown links are
// absent. Failed checks are not cached so a transient outage retries.
const _linkVerdicts = new Map();
function abortLinkVerification() {
_linkRequest?.controller.abort();
_linkRequest = null;
}
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
/**
* Refresh the InciWeb catalog off the render path: a hanging or failed
* fetch only costs the link line, never the perimeter refresh — the
* catalog is consumed solely by the selected card.
*/
function refreshInciwebIndex(signal) {
if (!inciwebSource || _indexFetchInFlight) return;
if (Date.now() - _indexFetchedAt < INCIWEB_INDEX_TTL_MS) return;
_indexFetchInFlight = true;
inciwebSource
.getIndex({ signal })
.then((rows) => {
if (signal.aborted || !_enabled || !Array.isArray(rows)) return;
_inciwebIndex = rows;
_indexFetchedAt = Date.now();
if (_selectedId) publishSelectedCard();
})
.catch(() => {
// Catalog unavailable — cards stay linkless until the next attempt.
})
.finally(() => {
_indexFetchInFlight = false;
});
}
/**
* Verify that a matched publication describes the current incident, not
* an archived same-name fire — the catalog is all-time. Async: the card
* republishes with the link once (and only if) the check passes.
*/
function verifyLink(link, row) {
const id = inciwebNodeId(link);
if (!id || !inciwebPublications || _linkRequest?.link === link) return;
abortLinkVerification();
const request = { link, controller: new AbortController() };
_linkRequest = request;
const { signal } = request.controller;
inciwebPublications
.getPublication(id, { signal })
.then((publication) => {
if (signal.aborted || _linkRequest !== request || !_enabled) return;
_linkVerdicts.set(
link,
isCurrentPublication(publication, {
discoveredTime: row.discoveredTime,
}),
);
if (_selectedId === row.stableId) publishSelectedCard();
})
.catch(() => {
// Fail closed without caching: no link now, retry on reselection.
})
.finally(() => {
if (_linkRequest === request) _linkRequest = null;
});
}
function publishSelectedCard() {
if (!canSelect()) return;
const row = _selectedId ? _rowById.get(_selectedId) : null;
if (!row) {
_selectedId = null;
_selectedLink = null;
_selectedCardId = null;
overlayHost.setEntries(
PERIMETER_OVERLAY_SOURCE_ID,
[],
CARD_HOST_OPTIONS,
);
return;
}
const candidate = findInciwebLink(_inciwebIndex, row);
if (candidate && !_linkVerdicts.has(candidate)) verifyLink(candidate, row);
_selectedLink =
candidate && _linkVerdicts.get(candidate) === true ? candidate : null;
const card = {
...buildIncidentCard(row, Date.now(), { link: _selectedLink }),
position: Cesium.Cartesian3.fromDegrees(row.anchor.lon, row.anchor.lat),
};
if (!openExternal) card.interactive = false;
if (_selectedLink && openExternal) {
const link = _selectedLink;
// Keyboard/assistive activation mirrors the pointer click-through.
card.activate = () => {
openExternal(link);
return true;
};
}
_selectedCardId = card.id;
overlayHost.setEntries(
PERIMETER_OVERLAY_SOURCE_ID,
[card],
CARD_HOST_OPTIONS,
);
}
/** Resolve a scene pick to one of this layer's incident ids, or null. */
function pickedIncidentId(picked) {
const pickId = picking.resolvePickId(picked);
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
return null;
const incidentId = pickId.slice(PICK_PREFIX.length).split(':')[0];
return _rowById.has(incidentId) ? incidentId : null;
}
function installClickHandler() {
if (!canSelect() || _clickHandler || !_viewer) return;
// Deliberately NOT registered in the pick-ownership registry: sibling
// layers yield to owned picks before their own overlay-card hit-tests,
// so claiming these large ground polygons would make FIRMS/vessel/CCTV
// cards inert anywhere over a perimeter. Unowned, our picks read as
// "empty space" to siblings — the pre-existing behavior for the globe.
_clickHandler = screenSpaceEventHandlerFactory(_viewer);
_clickHandler.setInputAction((click) => {
if (pointer && !pointer.isPointerFree()) return;
// A click on the incident card itself opens its InciWeb page (when the
// incident has one) and never disturbs the selection.
const cardHit = overlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{ sourceId: PERIMETER_OVERLAY_SOURCE_ID },
);
if (cardHit && cardHit.entryId === _selectedCardId) {
if (_selectedLink && openExternal) openExternal(_selectedLink);
return;
}
const picked = _viewer.scene.pick(click.position);
const incidentId = picked ? pickedIncidentId(picked) : null;
if (incidentId) {
abortLinkVerification();
_selectedId = incidentId;
publishSelectedCard();
return;
}
// A pick that belongs to a sibling layer (e.g. an aircraft) is not
// "empty space" — leave the selection alone and let that layer handle it.
if (picked) {
const pickId = picking.resolvePickId(picked);
if (pickId && picking.isOwnedByOtherLayer(layer.id, pickId)) return;
}
if (_selectedId) {
abortLinkVerification();
_selectedId = null;
publishSelectedCard();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function removeClickHandler() {
if (_clickHandler) {
_clickHandler.destroy();
_clickHandler = null;
}
}
function clearSelection() {
abortLinkVerification();
_selectedId = null;
_selectedLink = null;
_selectedCardId = null;
if (overlayHost) {
overlayHost.clearSource(PERIMETER_OVERLAY_SOURCE_ID);
overlayHost.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, false);
}
}
const layer = {
id: 'fire-perimeters',
name: 'Fire Perimeters',
icon: '🔥',
source: 'NIFC WFIGS',
updateInterval: 300000,
init(viewer) {
if (_viewer)
throw new Error('Fire perimeter layer is already initialized');
_viewer = viewer;
_dataSource = new Cesium.CustomDataSource('fire-perimeters');
_dataSource.show = false;
viewer.dataSources.add(_dataSource);
_count = 0;
_lastUpdate = null;
_lastError = null;
_enabled = false;
console.log('[Data:FirePerimeters] Initialized');
},
enable() {
_enabled = true;
if (_dataSource) _dataSource.show = true;
overlayHost?.setVisible?.(PERIMETER_OVERLAY_SOURCE_ID, true);
installClickHandler();
},
disable() {
_request?.abort();
_request = null;
_enabled = false;
if (_dataSource) _dataSource.show = false;
removeClickHandler();
clearSelection();
},
async update() {
if (!_enabled || !_dataSource) return false;
_request?.abort();
const request = new AbortController();
_request = request;
refreshInciwebIndex(request.signal);
try {
const rows = await source.getSnapshot({ signal: request.signal });
if (request.signal.aborted || _request !== request || !_enabled)
return false;
// Incident facts may change independently of the polygon's source clock.
const signature = JSON.stringify(
rows
.map(({ polygons, ...facts }) => facts)
.sort((a, b) => a.stableId.localeCompare(b.stableId)),
);
if (signature === _snapshotSignature) {
if (_selectedId) publishSelectedCard();
_lastUpdate = Date.now();
_lastError = null;
return true;
}
abortLinkVerification();
const nextEntities = [];
_rowById.clear();
for (const row of rows) {
const color = containmentColor(row.containedPct);
for (const [index, rings] of row.polygons.entries()) {
const [outer, ...holes] = rings;
const outerPositions = ringPositions(outer);
nextEntities.push(
new Cesium.Entity({
id: `fire-perimeter:${row.stableId}:${index}`,
polygon: {
hierarchy: new Cesium.PolygonHierarchy(
outerPositions,
holes.map(
(hole) =>
new Cesium.PolygonHierarchy(ringPositions(hole)),
),
),
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.25),
),
},
// The fire line: ground-clamped outline (entity polygons cannot
// outline clamped geometry themselves).
polyline: {
positions: outerPositions,
clampToGround: true,
width: 2,
material: new Cesium.ColorMaterialProperty(
color.withAlpha(0.9),
),
},
}),
);
}
// Keep a geometry-free projection: the coordinate arrays would
// otherwise be retained for the layer's lifetime while only the
// anchor is ever read again.
const { polygons, ...facts } = row;
_rowById.set(row.stableId, {
...facts,
anchor: perimeterAnchorDegrees(polygons),
});
}
_dataSource.entities.removeAll();
for (const entity of nextEntities) _dataSource.entities.add(entity);
_snapshotSignature = signature;
// Refresh (or drop) the selected incident's card against the new feed.
if (_selectedId) publishSelectedCard();
_count = rows.length;
_lastUpdate = Date.now();
_lastError = null;
console.log(
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
);
return true;
} catch (e) {
if (request.signal.aborted || _request !== request || !_enabled)
return false;
console.warn('[Data:FirePerimeters] Fetch error:', e);
_lastError = e?.message || 'Perimeter source unavailable';
return false;
} finally {
if (_request === request) _request = null;
}
},
destroy(viewer = _viewer) {
_request?.abort();
_request = null;
removeClickHandler();
clearSelection();
_rowById.clear();
_snapshotSignature = null;
_inciwebIndex = [];
_indexFetchedAt = 0;
_linkVerdicts.clear();
_viewer = null;
_enabled = false;
if (_dataSource) {
viewer.dataSources.remove(_dataSource, true);
_dataSource = null;
}
_count = 0;
_lastUpdate = null;
_lastError = null;
},
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
getAnalystRecords(maxCount = 2000) {
if (!_dataSource || !_dataSource.show) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const row of _rowById.values()) {
if (result.length >= limit) break;
const { anchor, stableId, ...facts } = row;
result.push({
id: stableId,
...facts,
lat: anchor.lat,
lon: anchor.lon,
});
}
return result;
},
getRowControls() {
const bands = [
{ label: 'Not contained or unknown', color: containmentAccent(0) },
{ label: 'Under 50% contained', color: containmentAccent(1) },
{ label: '50–99% contained', color: containmentAccent(50) },
{ label: 'Fully contained', color: containmentAccent(100) },
];
return {
chips: [],
legend: bands.map((band, index) => ({
...band,
count: [..._rowById.values()].filter(
(row) => containmentAccent(row.containedPct) === band.color,
).length,
...(index === 0
? {
blurb:
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
}
: {}),
})),
};
},
getStats() {
return {
count: _count,
lastUpdate: _lastUpdate,
error: _lastError,
};
},
};
return layer;
}
+554
View File
@@ -0,0 +1,554 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFirePerimetersLayer } from './index.js';
function harness(
source,
{
pick = () => null,
inciwebIndex = null,
cardHit = () => null,
publication = async () => ({
createdMs: 1757000000000,
changedMs: 1757900000000,
}),
} = {},
) {
const sources = [];
const overlay = { entries: new Map(), visible: null };
const clicks = { handler: null, destroyed: 0 };
const owners = new Map();
const opened = [];
const viewer = {
scene: { pick },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const layer = createFirePerimetersLayer({
source,
overlayHost: {
setEntries(sourceId, entries) {
overlay.entries.set(sourceId, entries);
},
setVisible(sourceId, visible) {
overlay.visible = visible;
},
clearSource(sourceId) {
overlay.entries.delete(sourceId);
},
hitTest: (x, y, options) => cardHit(x, y, options),
},
screenSpaceEventHandlerFactory: () => ({
setInputAction(callback) {
clicks.handler = callback;
},
destroy() {
clicks.destroyed += 1;
clicks.handler = null;
},
}),
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (layerId, pickedId) =>
String(pickedId).startsWith('other-layer:'),
registerPickOwner: (layerId, predicate) => owners.set(layerId, predicate),
unregisterPickOwner: (layerId) => owners.delete(layerId),
},
pointer: { isPointerFree: () => true },
inciwebSource: inciwebIndex
? { getIndex: async () => inciwebIndex }
: { getIndex: async () => [] },
inciwebPublications: { getPublication: publication },
openExternal: (url) => opened.push(url),
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, overlay, clicks, owners, opened };
}
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
const row = {
stableId: '2026-NMGNF-000123',
name: 'Fixture Fire',
acres: 512.5,
containedPct: 40,
state: 'US-NM',
category: 'WF',
discoveredTime: 1757900000000,
updatedTime: 1757950000000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
complexName: 'ROWE CREEK COMPLEX',
polygons: [[ring]],
};
test('each perimeter polygon renders as one filled entity with its fire line', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
],
});
assert.equal(await h.layer.update(h.viewer), true);
const entities = h.sources[0].entities.values;
assert.equal(entities.length, 3);
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
assert.ok(entities[0].polygon, 'perimeter fill missing');
assert.ok(entities[0].polyline, 'fire line missing');
assert.equal(entities[1].id, 'fire-perimeter:other:0');
assert.equal(entities[2].id, 'fire-perimeter:other:1');
assert.equal(h.layer.getStats().count, 2);
});
test('late refresh cannot publish after disable or destroy', async () => {
for (const action of ['disable', 'destroy']) {
let resolve, signal;
const h = harness({
getSnapshot(options) {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
h.layer.destroy(h.viewer);
}
});
test('clicking a perimeter publishes its incident card; empty space clears it', async () => {
let pickResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => pickResult,
},
);
await h.layer.update(h.viewer);
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
// Deliberately NOT registered as a pick owner: sibling layers' click
// handlers yield to owned picks before their own card hit-tests, so
// claiming these large ground polygons would make FIRMS/vessel/CCTV
// overlay cards inert anywhere over a perimeter.
assert.equal(h.owners.size, 0);
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' };
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
assert.equal(entries[0].title, 'FIRE · Fixture Fire');
assert.ok(entries[0].position, 'card must carry a world anchor');
pickResult = { id: 'other-layer:aircraft-1' };
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(
h.overlay.entries.get('fire-perimeters')?.length,
1,
'sibling-layer picks must not clear the selection',
);
pickResult = null;
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
test('disable removes the click handler, pick ownership, and any card', async () => {
const h = harness(
{ getSnapshot: async () => [row] },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
h.layer.disable(h.viewer);
assert.equal(h.clicks.destroyed, 1);
assert.equal(h.owners.has('fire-perimeters'), false);
assert.equal(h.overlay.entries.has('fire-perimeters'), false);
});
test('a refresh that drops the selected incident also drops its card', async () => {
let rows = [row];
const h = harness(
{ getSnapshot: async () => rows },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
rows = [{ ...row, stableId: 'different' }];
await h.layer.update(h.viewer);
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
test('a matched incident card carries the InciWeb line and click-through', async () => {
const inciwebIndex = [
{
incident_id: '329300',
tau: 'NMGNF Fixture Fire',
incident_title: 'Fixture Fire',
},
];
let cardHitResult = null;
const h = harness(
{ getSnapshot: async () => [{ ...row, name: 'Fixture' }] },
{
pick: () => null,
inciwebIndex,
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
// Select via a perimeter pick.
h.viewer.scene.pick = () => ({
id: 'fire-perimeter:2026-NMGNF-000123:0',
});
h.clicks.handler({ position: { x: 10, y: 10 } });
// The card publishes immediately; the link line lands once the
// publication's currency is validated.
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
// Clicking the card itself opens the InciWeb page.
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
assert.equal(
h.overlay.entries.get('fire-perimeters').length,
1,
'opening the link must not clear the selection',
);
});
test('an unmatched incident renders no InciWeb line and card clicks stay inert', async () => {
let cardHitResult = null;
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex: [],
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, []);
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
});
test('a complex member resolves to its complex page from the catalog', async () => {
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex: [
{
// Same state as the member fire: the state filter applies to
// complex fallbacks too.
incident_id: '328923',
tau: 'NMPRD Rowe Creek Complex',
incident_title: 'Rowe Creek Complex',
},
],
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
});
test('a stale or unverifiable publication yields no link', async () => {
const inciwebIndex = [
{
incident_id: '100',
tau: 'NMGNF Fixture Fire',
incident_title: 'Fixture Fire',
},
];
for (const publication of [
// Created two years before this fire's discovery: an archived page.
async () => ({
createdMs: 1757900000000 - 700 * 86400000,
changedMs: null,
}),
// Validation endpoint down: fail closed, no link.
async () => {
throw new Error('InciWeb HTTP 503');
},
]) {
const h = harness(
{ getSnapshot: async () => [row] },
{
pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }),
inciwebIndex,
publication,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
assert.equal(entries[0].interactive, false);
h.layer.destroy(h.viewer);
}
});
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
// Replace the index source with one that never resolves.
h.layer.destroy(h.viewer);
const hanging = harness({ getSnapshot: async () => [row] });
hanging.layer.destroy(hanging.viewer);
const sources = [];
const viewer = {
scene: { pick: () => null },
dataSources: {
add: (v) => sources.push(v),
remove: (v) => sources.splice(sources.indexOf(v), 1),
},
};
const layer = createFirePerimetersLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: { getIndex: () => new Promise(() => {}) },
inciwebPublications: {
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
},
openExternal: () => {},
});
layer.init(viewer);
layer.enable(viewer);
assert.equal(await layer.update(viewer), true);
assert.equal(layer.getStats().count, 1);
layer.destroy(viewer);
});
test('an InciWeb outage never breaks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] });
const failing = createFirePerimetersLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: {
getIndex: async () => {
throw new Error('InciWeb HTTP 503');
},
},
openExternal: () => {},
});
const viewer = {
scene: { pick: () => null },
dataSources: { add() {}, remove() {} },
};
failing.init(viewer);
failing.enable(viewer);
assert.equal(await failing.update(viewer), true);
assert.equal(failing.getStats().count, 1);
failing.destroy(viewer);
h.layer.destroy(h.viewer);
});
test('analyst records expose incident facts without geometry payloads', async () => {
const h = harness({ getSnapshot: async () => [row] });
await h.layer.update(h.viewer);
const records = h.layer.getAnalystRecords();
assert.equal(records.length, 1);
assert.equal(records[0].name, 'Fixture Fire');
assert.equal(records[0].acres, 512.5);
assert.equal(records[0].containedPct, 40);
assert.equal(records[0].state, 'US-NM');
assert.equal(records[0].cause, 'Natural');
assert.equal(records[0].behavior, 'Active');
assert.equal(records[0].personnel, 380);
assert.equal(records[0].county, 'Sandoval');
assert.equal(records[0].costToDate, 4200000);
assert.equal(records[0].complexity, 'Type 3 Incident');
// Spatial scoping in the analyst engine requires coordinates.
assert.ok(Math.abs(records[0].lat - 35.2333) < 0.01);
assert.ok(Math.abs(records[0].lon - -108.0333) < 0.01);
assert.equal('polygons' in records[0], false);
});
test('unchanged snapshots retain entity identity even if source ordering changes', async () => {
let rows = [row, { ...row, stableId: 'other' }];
const h = harness({ getSnapshot: async () => rows });
await h.layer.update();
const original = [...h.sources[0].entities.values];
let removals = 0;
const entities = h.sources[0].entities;
const removeAll = entities.removeAll.bind(entities);
entities.removeAll = () => {
removals++;
removeAll();
};
rows = [...rows].reverse();
await h.layer.update();
assert.equal(removals, 0);
assert.equal(entities.values[0], original[0]);
rows = rows.map((value) => ({
...value,
updatedTime: value.updatedTime + 1,
}));
await h.layer.update();
assert.equal(removals, 1);
assert.notEqual(entities.values[0], original[0]);
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
await h.layer.update();
assert.equal(
removals,
2,
'incident facts can change without a new polygon timestamp',
);
assert.equal(h.layer.getRowControls().legend[3].count, 2);
rows = [];
await h.layer.update();
assert.equal(entities.values.length, 0);
h.layer.destroy();
});
test('containment legend uses the existing thresholds, colours and incident counts', async () => {
const values = [null, -1, 0, 0.5, 49.9, 50, 99.9, 100, 101];
const h = harness({
getSnapshot: async () =>
values.map((containedPct, index) => ({
...row,
stableId: String(index),
containedPct,
})),
});
await h.layer.update();
const controls = h.layer.getRowControls();
assert.deepEqual(controls.chips, []);
assert.deepEqual(
controls.legend.map(({ color, count }) => [color, count]),
[
['#ff3b30', 3],
['#ff7a00', 2],
['#ffb300', 2],
['#8bc34a', 2],
],
);
assert.ok(
controls.legend.every(
({ label }) => typeof label === 'string' && label.length,
),
);
assert.equal(
controls.legend[0].blurb,
'Colour shows reported containment. Perimeters are simplified to about 100 m.',
);
h.layer.destroy();
assert.deepEqual(
h.layer.getRowControls().legend.map(({ count }) => count),
[0, 0, 0, 0],
);
});
for (const action of ['disable', 'destroy', 'selection']) {
test(`incident-link verification aborts on ${action} and ignores late completion`, async () => {
let finish, signal;
let pick = { id: `fire-perimeter:${row.stableId}:0` };
const h = harness(
{
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', name: 'Other Fire' },
],
},
{
pick: () => pick,
inciwebIndex: [
{ incident_id: '123', incident_title: row.name, tau: 'nm' },
],
publication: (_id, options) => {
signal = options.signal;
return new Promise((resolve) => {
finish = resolve;
});
},
},
);
await h.layer.update();
h.clicks.handler({ position: { x: 1, y: 1 } });
assert.equal(signal.aborted, false);
if (action === 'selection') {
pick = { id: 'fire-perimeter:other:0' };
h.clicks.handler({ position: { x: 1, y: 1 } });
} else h.layer[action]();
assert.equal(signal.aborted, true);
const entries = h.overlay.entries.get('fire-perimeters');
finish({ createdMs: row.discoveredTime, changedMs: row.updatedTime });
await new Promise(setImmediate);
assert.equal(h.overlay.entries.get('fire-perimeters'), entries);
if (action !== 'destroy') h.layer.destroy();
});
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Normalize a WFIGS feed, skipping individually invalid features.
* Only a payload that is not a feature collection at all rejects the whole
* snapshot: a single degenerate incident (ArcGIS emits geometry:null when
* maxAllowableOffset generalizes a tiny polygon away) must never blank the
* layer — a failed first update makes the lifecycle disable it entirely.
*/
const finiteOrNull = (value) => (Number.isFinite(value) ? value : null);
const textOrNull = (value) => (typeof value === 'string' ? value : null);
function validRing(ring) {
if (!Array.isArray(ring) || ring.length < 4) return false;
for (const position of ring) {
if (!Array.isArray(position) || position.length < 2) return false;
const [lon, lat] = position;
if (!Number.isFinite(lon) || Math.abs(lon) > 180) return false;
if (!Number.isFinite(lat) || Math.abs(lat) > 90) return false;
}
return true;
}
/** Normalize Polygon/MultiPolygon geometry to an array of polygons (each an
* array of rings). Returns null on malformed geometry, [] when empty. */
function normalizePolygons(geometry) {
if (!geometry || typeof geometry !== 'object') return null;
let polygons;
if (geometry.type === 'Polygon') polygons = [geometry.coordinates];
else if (geometry.type === 'MultiPolygon') polygons = geometry.coordinates;
else return null;
if (!Array.isArray(polygons)) return null;
const result = [];
for (const rings of polygons) {
if (!Array.isArray(rings)) return null;
if (!rings.length) continue;
if (!rings.every(validRing)) return null;
result.push(rings);
}
return result;
}
export function normalizeFirePerimeterSnapshot(geojson) {
if (!Array.isArray(geojson?.features)) return null;
const rows = [];
const ids = new Set();
for (const feature of geojson.features) {
const properties = feature?.properties;
if (
!properties ||
typeof properties !== 'object' ||
Array.isArray(properties)
)
continue;
const polygons = normalizePolygons(feature.geometry);
if (polygons === null || !polygons.length) continue;
const uniqueId = properties.attr_UniqueFireIdentifier;
const stableId =
typeof uniqueId === 'string' && uniqueId !== ''
? uniqueId
: feature.id == null || feature.id === ''
? null
: String(feature.id);
if (stableId == null || ids.has(stableId)) continue;
ids.add(stableId);
rows.push({
stableId,
name:
typeof properties.poly_IncidentName === 'string'
? properties.poly_IncidentName
: null,
acres: finiteOrNull(properties.attr_IncidentSize),
containedPct: finiteOrNull(properties.attr_PercentContained),
state:
typeof properties.attr_POOState === 'string'
? properties.attr_POOState
: null,
category:
typeof properties.attr_IncidentTypeCategory === 'string'
? properties.attr_IncidentTypeCategory
: null,
discoveredTime: finiteOrNull(properties.attr_FireDiscoveryDateTime),
updatedTime: finiteOrNull(properties.poly_DateCurrent),
cause: textOrNull(properties.attr_FireCause),
behavior: textOrNull(properties.attr_FireBehaviorGeneral),
personnel: finiteOrNull(properties.attr_TotalIncidentPersonnel),
county: textOrNull(properties.attr_POOCounty),
costToDate: finiteOrNull(properties.attr_EstimatedCostToDate),
complexity: textOrNull(properties.attr_IncidentComplexityLevel),
complexName: textOrNull(properties.attr_CpxName),
polygons,
});
}
return rows;
}
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { normalizeFirePerimeterSnapshot } from './records.js';
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
function feature(overrides = {}) {
return {
id: 1,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
poly_IncidentName: 'Fixture Fire',
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
attr_IncidentSize: 512.5,
attr_PercentContained: 40,
attr_POOState: 'US-NM',
attr_IncidentTypeCategory: 'WF',
attr_FireDiscoveryDateTime: 1757900000000,
poly_DateCurrent: 1757950000000,
attr_FireCause: 'Natural',
attr_FireBehaviorGeneral: 'Active',
attr_TotalIncidentPersonnel: 380,
attr_POOCounty: 'Sandoval',
attr_EstimatedCostToDate: 4200000,
attr_IncidentComplexityLevel: 'Type 3 Incident',
attr_CpxName: 'ROWE CREEK COMPLEX',
...overrides.properties,
},
...overrides,
};
}
test('a valid snapshot yields one row per incident with polygons preserved', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature(),
feature({
id: 2,
geometry: {
type: 'MultiPolygon',
coordinates: [[ring], [ring]],
},
properties: { attr_UniqueFireIdentifier: '2026-AZASF-000456' },
}),
],
});
assert.equal(rows.length, 2);
assert.deepEqual(rows[0], {
stableId: '2026-NMGNF-000123',
name: 'Fixture Fire',
acres: 512.5,
containedPct: 40,
state: 'US-NM',
category: 'WF',
discoveredTime: 1757900000000,
updatedTime: 1757950000000,
cause: 'Natural',
behavior: 'Active',
personnel: 380,
county: 'Sandoval',
costToDate: 4200000,
complexity: 'Type 3 Incident',
complexName: 'ROWE CREEK COMPLEX',
polygons: [[ring]],
});
assert.equal(rows[1].polygons.length, 2);
});
test('a missing unique fire identifier falls back to the feature id', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [feature({ properties: { attr_UniqueFireIdentifier: null } })],
});
assert.equal(rows[0].stableId, '1');
});
test('optional attributes normalize to null rather than leaking undefined', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature({
properties: {
poly_IncidentName: null,
attr_IncidentSize: null,
attr_PercentContained: null,
attr_POOState: null,
attr_IncidentTypeCategory: null,
attr_FireDiscoveryDateTime: null,
poly_DateCurrent: null,
attr_FireCause: null,
attr_FireBehaviorGeneral: null,
attr_TotalIncidentPersonnel: null,
attr_POOCounty: null,
attr_EstimatedCostToDate: null,
attr_IncidentComplexityLevel: null,
attr_CpxName: null,
},
}),
],
});
assert.deepEqual(
{
name: rows[0].name,
acres: rows[0].acres,
containedPct: rows[0].containedPct,
state: rows[0].state,
category: rows[0].category,
discoveredTime: rows[0].discoveredTime,
updatedTime: rows[0].updatedTime,
cause: rows[0].cause,
behavior: rows[0].behavior,
personnel: rows[0].personnel,
county: rows[0].county,
costToDate: rows[0].costToDate,
complexity: rows[0].complexity,
complexName: rows[0].complexName,
},
{
name: null,
acres: null,
containedPct: null,
state: null,
category: null,
discoveredTime: null,
updatedTime: null,
cause: null,
behavior: null,
personnel: null,
county: null,
costToDate: null,
complexity: null,
complexName: null,
},
);
});
test('a feed that is not a feature collection is rejected as a whole', () => {
for (const payload of [null, {}, { features: null }, { features: {} }]) {
assert.equal(normalizeFirePerimeterSnapshot(payload), null);
}
});
test('an invalid feature is skipped so one bad incident cannot blank the layer', () => {
const good = feature({
id: 2,
properties: { attr_UniqueFireIdentifier: 'good' },
});
for (const bad of [
feature({ geometry: null }),
feature({ geometry: { type: 'Point', coordinates: [0, 1] } }),
feature({
geometry: {
type: 'Polygon',
coordinates: [[[Infinity, 35.2], ...ring.slice(1)]],
},
}),
feature({
geometry: {
type: 'Polygon',
coordinates: [[[-200, 35.2], ...ring.slice(1)]],
},
}),
feature({ geometry: { type: 'Polygon', coordinates: [ring.slice(0, 2)] } }),
feature({ properties: null }),
feature({ id: null, properties: { attr_UniqueFireIdentifier: null } }),
]) {
const rows = normalizeFirePerimeterSnapshot({ features: [bad, good] });
assert.equal(rows.length, 1, 'bad feature must be skipped, not fatal');
assert.equal(rows[0].stableId, 'good');
}
});
test('a duplicate incident id keeps the first occurrence and skips the rest', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature(),
feature({
properties: {
attr_UniqueFireIdentifier: '2026-NMGNF-000123',
attr_IncidentSize: 999,
},
}),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].acres, 512.5);
});
test('a perimeter with no rings is skipped rather than rendered empty', () => {
const rows = normalizeFirePerimeterSnapshot({
features: [
feature({ geometry: { type: 'Polygon', coordinates: [] } }),
feature({ id: 2, properties: { attr_UniqueFireIdentifier: 'other' } }),
],
});
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, 'other');
});
+23
View File
@@ -0,0 +1,23 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
/** Request a normalized snapshot through the bounded, same-origin WFIGS proxy. */
export function createWfigsPerimeterSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/fire-perimeters', { signal });
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
const payload = await readResponseJsonCapped(
response,
80 * 1024 * 1024,
signal,
);
signal?.throwIfAborted();
if (!Array.isArray(payload?.rows))
throw new Error('Malformed perimeter snapshot');
return payload.rows;
},
};
}
+117
View File
@@ -0,0 +1,117 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWfigsPerimeterSource } from './source.js';
import { normalizeFirePerimeterSnapshot } from './records.js';
import { firePerimetersProxy } from '../../../server/providers/firePerimeters.js';
const ring = [
[-108.1, 35.2],
[-108.0, 35.2],
[-108.0, 35.3],
[-108.1, 35.2],
];
const validPayload = {
features: [
{
id: 1,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { attr_UniqueFireIdentifier: '2026-NMGNF-000123' },
},
],
};
test('a successful response yields normalized perimeter rows', async () => {
let requested;
const source = createWfigsPerimeterSource({
fetchImpl: async (url) => {
requested = String(url);
return Response.json({
rows: normalizeFirePerimeterSnapshot(validPayload),
});
},
});
const rows = await source.getSnapshot();
assert.equal(rows.length, 1);
assert.equal(rows[0].stableId, '2026-NMGNF-000123');
assert.equal(requested, '/api/fire-perimeters');
});
test('a truncated response pages until the feed is complete', async () => {
const pageRing = (id) => ({
id,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: { attr_UniqueFireIdentifier: `fire-${id}` },
});
const requests = [];
let handler;
const plugin = firePerimetersProxy({
fetchImpl: async (url) => {
requests.push(new URL(String(url)).searchParams.get('resultOffset'));
const page = requests.length;
return Response.json({
features: [pageRing(page)],
properties: { exceededTransferLimit: page < 3 },
});
},
});
plugin.configureServer({
middlewares: {
use: (_path, callback) => {
handler = callback;
},
},
});
let payload;
await handler(
{ url: '/', method: 'GET' },
{
writeHead(status) {
assert.equal(status, 200);
},
end(body) {
payload = JSON.parse(body);
},
},
);
const rows = payload.rows;
assert.deepEqual(
rows.map((row) => row.stableId),
['fire-1', 'fire-2', 'fire-3'],
);
assert.deepEqual(requests, [null, '1', '2']);
});
test('an upstream failure surfaces its HTTP status', async () => {
const source = createWfigsPerimeterSource({
fetchImpl: async () => ({ ok: false, status: 503 }),
});
await assert.rejects(source.getSnapshot(), /WFIGS HTTP 503/);
});
test('a malformed successful response is never accepted as an empty snapshot', async () => {
for (const payload of [{}, { rows: null }, { rows: {} }]) {
const source = createWfigsPerimeterSource({
fetchImpl: async () => Response.json(payload),
});
await assert.rejects(source.getSnapshot(), /Malformed perimeter snapshot/);
}
});
test('response-body completion honors cancellation without replacing records', async () => {
const abort = new AbortController();
const source = createWfigsPerimeterSource({
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
text: async () => {
abort.abort();
return JSON.stringify({
rows: normalizeFirePerimeterSnapshot(validPayload),
});
},
}),
});
await assert.rejects(source.getSnapshot({ signal: abort.signal }), {
name: 'AbortError',
});
});
+219
View File
@@ -0,0 +1,219 @@
/**
* NASA CMR granule catalog for the HLS products, folded with the VIIRS
* overview days into one candidate list.
*/
import {
CATALOG_DAYS,
PRODUCTS,
coverageFor,
groupGranulesByDay,
mergeCandidates,
utcDay,
validateBox,
viirsCandidates,
} from './model.js';
const CMR_ENDPOINT = 'https://cmr.earthdata.nasa.gov/search';
const DAY_MS = 86_400_000;
const CMR_PAGE_SIZE = 200;
/**
* Most records read per collection. A 1,000 km box can touch hundreds of HLS
* tiles a day; past this cap the newest days (the sort order) are kept and
* the result is `truncated`.
*/
const CMR_MAX_RECORDS = 2000;
const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
export async function fetchCmrPages({
product,
url,
fetchImpl,
signal,
maxRecords = CMR_MAX_RECORDS,
}) {
const granules = [];
let hits = 0;
let cursor = null;
for (;;) {
const init = { signal };
if (cursor) init.headers = { [CMR_SEARCH_AFTER]: cursor };
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
}
function finiteOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function isoOrNull(value) {
return typeof value === 'string' && Number.isFinite(Date.parse(value))
? value
: null;
}
function ringOf(points) {
const ring = points
.map(([lon, lat]) => [finiteOrNull(lon), finiteOrNull(lat)])
.filter(([lon, lat]) => lon !== null && lat !== null);
return ring.length >= 3 ? ring : null;
}
function footprintOf(geometry) {
const polygon = geometry?.GPolygons?.[0]?.Boundary?.Points;
if (Array.isArray(polygon)) {
const ring = ringOf(polygon.map((p) => [p?.Longitude, p?.Latitude]));
if (ring) return ring;
}
const r = geometry?.BoundingRectangles?.[0];
if (!r) return null;
const [w, s, e, n] = [
r.WestBoundingCoordinate,
r.SouthBoundingCoordinate,
r.EastBoundingCoordinate,
r.NorthBoundingCoordinate,
];
const ring = ringOf([
[w, s],
[e, s],
[e, n],
[w, n],
]);
return ring?.length === 4 ? ring : null;
}
/**
* Parse a CMR `granules.umm_json` response. Every field is optional upstream;
* granules without a parseable start time are dropped.
* @param {object} json
* @param {string} product `S30` | `L30`
* @returns {{ granules: Array<object>, hits: number }}
*/
export function parseCmrUmm(json, product) {
const granules = [];
for (const item of Array.isArray(json?.items) ? json.items : []) {
const umm = item?.umm || {};
const meta = item?.meta || {};
const range = umm.TemporalExtent?.RangeDateTime || {};
const timeStart = isoOrNull(range.BeginningDateTime);
if (!timeStart) continue;
const cloud = Array.isArray(umm.AdditionalAttributes)
? umm.AdditionalAttributes.find((a) => a?.Name === 'CLOUD_COVERAGE')
?.Values?.[0]
: null;
granules.push({
id: String(
meta['concept-id'] || umm.GranuleUR || meta['native-id'] || '',
),
product,
timeStart,
timeEnd: isoOrNull(range.EndingDateTime) || timeStart,
cloud: finiteOrNull(cloud),
footprint: footprintOf(
umm.SpatialExtent?.HorizontalSpatialDomain?.Geometry,
),
});
}
return { granules, hits: finiteOrNull(json?.hits) ?? granules.length };
}
/**
* CMR granule search URL for one HLS product over a box and time window.
* @param {{ product: string, box: object, startIso: string, endIso: string }} query
* @returns {string}
*/
export function cmrSearchUrl({ product, box, startIso, endIso }) {
const spec = PRODUCTS[product];
if (!spec?.cmrCollection)
throw new TypeError(`No CMR collection for product: ${product}`);
const result = validateBox(box);
if (!result.ok) throw new TypeError(result.message);
const { west, south, east, north } = result.box;
return (
`${CMR_ENDPOINT}/granules.umm_json` +
`?collection_concept_id=${spec.cmrCollection}` +
`&bounding_box=${west},${south},${east},${north}` +
`&temporal=${startIso},${endIso}` +
`&sort_key=-start_date&page_size=${CMR_PAGE_SIZE}`
);
}
/**
* Search both HLS collections concurrently and fold the results with the
* VIIRS overview days. One product failing still yields the other's days plus
* an `errors` entry; an invalid box or clock throws a TypeError.
* @param {{ box: object, days?: number, now?: Date, fetchImpl?: typeof fetch, signal?: AbortSignal }} request
* @returns {Promise<{ candidates: Array<object>, truncated: boolean, errors: Array<{ product: string, message: string }> }>}
*/
export async function searchHls({
box,
days = CATALOG_DAYS,
now = new Date(),
fetchImpl = globalThis.fetch,
signal,
} = {}) {
const validated = validateBox(box);
if (!validated.ok) throw new TypeError(validated.message);
if (typeof fetchImpl !== 'function')
throw new TypeError('A fetch implementation is required');
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
if (!Number.isFinite(nowMs)) throw new TypeError('A valid clock is required');
const endIso = new Date(nowMs).toISOString();
const startIso = `${utcDay(new Date(nowMs - (days - 1) * DAY_MS))}T00:00:00Z`;
const products = ['S30', 'L30'];
const settled = await Promise.allSettled(
products.map((product) =>
fetchCmrPages({
product,
url: cmrSearchUrl({ product, box: validated.box, startIso, endIso }),
fetchImpl,
signal,
}),
),
);
const granules = [];
const errors = [];
let truncated = false;
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
granules.push(...result.value.granules);
truncated ||= result.value.truncated;
} else {
errors.push({
product: products[index],
message: String(result.reason?.message || result.reason),
});
}
});
const hls = groupGranulesByDay(granules).map((candidate) => ({
...candidate,
coverage: coverageFor(candidate, validated.box),
}));
return {
candidates: mergeCandidates([hls, viirsCandidates(endIso, days)]),
truncated,
errors,
};
}
+296
View File
@@ -0,0 +1,296 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
cmrSearchUrl,
fetchCmrPages,
parseCmrUmm,
searchHls,
} from './catalog.js';
import { BOX, response } from './testDoubles.mjs';
function ummItem({
conceptId = 'G1-LPCLOUD',
begin = '2026-09-18T17:12:01.000Z',
end = '2026-09-18T17:12:31.000Z',
cloud = '12',
polygon = [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
rectangle = null,
} = {}) {
const Geometry = {};
if (polygon)
Geometry.GPolygons = [
{
Boundary: {
Points: polygon.map(([Longitude, Latitude]) => ({
Longitude,
Latitude,
})),
},
},
];
if (rectangle)
Geometry.BoundingRectangles = [
{
WestBoundingCoordinate: rectangle[0],
SouthBoundingCoordinate: rectangle[1],
EastBoundingCoordinate: rectangle[2],
NorthBoundingCoordinate: rectangle[3],
},
];
return {
meta: { 'concept-id': conceptId },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: begin, EndingDateTime: end },
},
AdditionalAttributes: [
{ Name: 'SPATIAL_COVERAGE', Values: ['100'] },
...(cloud === undefined
? []
: [{ Name: 'CLOUD_COVERAGE', Values: [cloud] }]),
],
SpatialExtent: { HorizontalSpatialDomain: { Geometry } },
},
};
}
test('umm_json granules carry id, times, scene cloud and a footprint ring', () => {
const { granules, hits } = parseCmrUmm(
{
hits: 9,
items: [
ummItem(),
ummItem({
conceptId: 'G2',
cloud: '3.5',
polygon: null,
rectangle: [-98.5, 29.5, -97.5, 30.5],
}),
{
meta: {},
umm: {
GranuleUR: 'HLS.L30.only-ur',
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-01T16:00:00Z' },
},
},
},
ummItem({ conceptId: 'G-bad-cloud', cloud: 'n/a' }),
ummItem({
conceptId: 'G-two-points',
polygon: [
[0, 0],
[1, 1],
],
}),
{ meta: { 'concept-id': 'G-no-time' }, umm: {} },
{
meta: { 'concept-id': 'G-bad-time' },
umm: {
TemporalExtent: { RangeDateTime: { BeginningDateTime: 'soon' } },
},
},
null,
],
},
'S30',
);
assert.equal(hits, 9);
assert.deepEqual(granules[0], {
id: 'G1-LPCLOUD',
product: 'S30',
timeStart: '2026-09-18T17:12:01.000Z',
timeEnd: '2026-09-18T17:12:31.000Z',
cloud: 12,
footprint: [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
});
assert.equal(granules[1].cloud, 3.5);
assert.deepEqual(granules[1].footprint, [
[-98.5, 29.5],
[-97.5, 29.5],
[-97.5, 30.5],
[-98.5, 30.5],
]);
assert.deepEqual(
granules.slice(2).map((g) => [g.id, g.cloud, g.footprint]),
[
['HLS.L30.only-ur', null, null],
['G-bad-cloud', null, granules[0].footprint],
['G-two-points', 12, null],
],
'missing fields stay unknown; granules without a start time are dropped',
);
assert.equal(granules[2].timeEnd, '2026-09-01T16:00:00Z');
assert.deepEqual(parseCmrUmm(null, 'S30'), { granules: [], hits: 0 });
});
test('the CMR query names the collection, box, window and page size with literal commas', () => {
assert.equal(
cmrSearchUrl({
product: 'S30',
box: BOX,
startIso: '2026-08-23T00:00:00Z',
endIso: '2026-09-21T12:00:00.000Z',
}),
'https://cmr.earthdata.nasa.gov/search/granules.umm_json?collection_concept_id=C2021957295-LPCLOUD&bounding_box=-97.8,30.2,-97.7,30.3&temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00.000Z&sort_key=-start_date&page_size=200',
);
assert.match(
cmrSearchUrl({ product: 'L30', box: BOX, startIso: 'a', endIso: 'b' }),
/collection_concept_id=C2021957657-LPCLOUD/,
);
assert.throws(
() => cmrSearchUrl({ product: 'VIIRS', box: BOX }),
/No CMR collection/,
);
});
/** Route a CMR request to the S30 or L30 handler by collection id. */
function cmrFetch(handlers, calls = []) {
return async (url, init) => {
calls.push({ url, init });
const handler = handlers[url.includes('C2021957295') ? 'S30' : 'L30'];
return typeof handler === 'function'
? handler()
: response({ json: handler });
};
}
test('both HLS collections fold with the VIIRS days; one failing product is reported, not fatal', async () => {
const calls = [];
const signal = new AbortController().signal;
const result = await searchHls({
box: BOX,
now: new Date('2026-09-21T12:00:00Z'),
signal,
fetchImpl: cmrFetch(
{
S30: {
hits: 2,
items: [
ummItem(),
ummItem({ conceptId: 'G-old', begin: '2026-09-08T17:12:01Z' }),
],
},
L30: () => response({ ok: false, status: 503 }),
},
calls,
),
});
assert.deepEqual(result.errors, [
{ product: 'L30', message: 'HTTP 503 from CMR' },
]);
assert.equal(result.truncated, false);
assert.equal(calls[0].init.signal, signal);
assert.match(
calls[0].url,
/temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00\.000Z/,
'30 UTC days ending now',
);
const keys = result.candidates.map((c) => c.key);
assert.equal(keys.length, 32);
assert.deepEqual(keys.slice(2, 5), [
'VIIRS:2026-09-19',
'S30:2026-09-18',
'VIIRS:2026-09-18',
]);
assert.ok(keys.includes('S30:2026-09-08'));
assert.equal(
result.candidates.find((c) => c.key === 'S30:2026-09-18').coverage,
'full',
'coverage is computed against the box',
);
const thrown = await searchHls({
box: BOX,
days: 2,
now: '2026-09-21T00:00:00Z',
fetchImpl: cmrFetch({
S30: () => {
throw new Error('network down');
},
L30: { hits: 500, items: [ummItem({ conceptId: 'G-L30' })] },
}),
});
assert.deepEqual(thrown.errors, [
{ product: 'S30', message: 'network down' },
]);
assert.equal(thrown.truncated, true, 'CMR has more hits than were read');
assert.deepEqual(
thrown.candidates.map((c) => c.key),
['VIIRS:2026-09-21', 'VIIRS:2026-09-20', 'L30:2026-09-18'],
);
});
test('an invalid box or clock is refused before any request', async () => {
let called = false;
const fetchImpl = async () => {
called = true;
};
await assert.rejects(
searchHls({
box: { west: 179, south: 0, east: -179, north: 1 },
fetchImpl,
}),
/dateline/,
);
await assert.rejects(
searchHls({ box: BOX, now: 'never', fetchImpl }),
/clock/,
);
assert.equal(called, false);
});
test('CMR pages follow the CMR-Search-After cursor up to 2,000 records and truncation stays honest', async () => {
const items = (count) =>
Array.from({ length: count }, (_, i) => ummItem({ conceptId: `G-${i}` }));
const cursors = [];
const paged = await fetchCmrPages({
product: 'S30',
url: 'https://cmr/x',
fetchImpl: async (url, init) => {
const page = cursors.push(init.headers?.['CMR-Search-After'] ?? null);
return response({
headers: { 'CMR-Search-After': `cursor-${page}` },
json: { hits: 450, items: items(page < 3 ? 200 : 50) },
});
},
});
assert.equal(paged.granules.length, 450);
assert.equal(paged.truncated, false);
assert.deepEqual(cursors, [null, 'cursor-1', 'cursor-2']);
let pages = 0;
const capped = await fetchCmrPages({
product: 'L30',
url: 'https://cmr/y',
fetchImpl: async () => {
pages += 1;
return response({
headers: { 'cmr-search-after': 'more' },
json: { hits: 5000, items: items(200) },
});
},
});
assert.equal(pages, 10);
assert.equal(capped.granules.length, 2000);
assert.equal(capped.truncated, true);
const noCursor = await fetchCmrPages({
product: 'S30',
url: 'u',
fetchImpl: async () => response({ json: { hits: 900, items: items(200) } }),
});
assert.equal(noCursor.granules.length, 200);
assert.equal(noCursor.truncated, true);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+613
View File
@@ -0,0 +1,613 @@
/**
* Pure domain model for the Recent Imagery layer: products, the selection box,
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
* no network.
*/
/** How far back the catalog looks, in UTC days. */
export const CATALOG_DAYS = 30;
const MAX_BOX_SIDE_KM = 1000;
/** ZOOM IN lands where the top-down view is this wide, well inside the cap. */
const FIT_VIEW_KM = 400;
const FIT_HEIGHT_KM = Object.freeze({ min: 5, max: 400 });
/** GIBS EPSG:3857 tiles do not exist beyond the Web-Mercator limit. */
const MERCATOR_LAT_LIMIT = 85.0511;
const MAX_CLOUD_FOR_CLEAR = 20;
const PIN_BOX_SIDE_KM = 10;
const WVS_ENDPOINT = 'https://wvs.earthdata.nasa.gov/api/v1/snapshot';
const KM_PER_DEG_LAT = 111.32;
const QUANTUM = 100000;
const DAY_MS = 86_400_000;
const PRODUCT_ORDER = ['S30', 'L30', 'VIIRS'];
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
/**
* Keyless, browser-direct imagery products. HLS products are searched via
* CMR; the VIIRS overview is a daily global mosaic with no granule catalog.
* `source` is the on/off group the row chips toggle.
*/
export const PRODUCTS = Object.freeze({
S30: Object.freeze({
label: 'HLS S30',
name: 'Sentinel-2',
source: 'hls',
sensor: 'Sentinel-2 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957295-LPCLOUD',
overview: false,
}),
L30: Object.freeze({
label: 'HLS L30',
name: 'Landsat 8/9',
source: 'hls',
sensor: 'Landsat 8/9 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957657-LPCLOUD',
overview: false,
}),
VIIRS: Object.freeze({
label: 'VIIRS',
name: 'Daily overview',
source: 'viirs',
sensor: 'VIIRS NOAA-21',
resolutionM: 250,
gibsLayer: 'VIIRS_NOAA21_CorrectedReflectance_TrueColor',
maxLevel: 9,
format: 'jpg',
cmrCollection: null,
overview: true,
}),
});
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const toRadians = (degrees) => (degrees * Math.PI) / 180;
const pad2 = (value) => String(value).padStart(2, '0');
/**
* Coerce a box to `{ west, south, east, north }` with south ≤ north. West and
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
box.west,
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
return {
west,
south: Math.min(south, north),
east,
north: Math.max(south, north),
};
}
/**
* Side lengths of a box in km: the box's own longitude span (never the
* shorter way round the globe) at its mid-latitude, and its latitude span.
* @param {object} box Degrees box.
* @returns {{ width: number, height: number }}
*/
export function boxSideKm(box) {
const normalized = normalizeBox(box);
if (!normalized) return { width: NaN, height: NaN };
const midLat = (normalized.south + normalized.north) / 2;
return {
width:
(normalized.east - normalized.west) *
KM_PER_DEG_LAT *
Math.cos(toRadians(midLat)),
height: (normalized.north - normalized.south) * KM_PER_DEG_LAT,
};
}
const refuse = (reason, message) => ({ ok: false, reason, message });
/**
* Validate a selection box for the catalog and the tile providers.
* @param {object} box Degrees box.
* @returns {{ ok: true, box: object } | { ok: false, reason: 'dateline' | 'polar' | 'degenerate' | 'too-large' | 'invalid', message: string }}
*/
export function validateBox(box) {
const normalized = normalizeBox(box);
if (!normalized) return refuse('invalid', 'Box edges must be finite');
const { west, south, east, north } = normalized;
if (Math.abs(west) > 180 || Math.abs(east) > 180)
return refuse('invalid', 'Longitudes must be within ±180°');
if (west > east) return refuse('dateline', 'Select one side of the dateline');
if (
Math.abs(south) > MERCATOR_LAT_LIMIT ||
Math.abs(north) > MERCATOR_LAT_LIMIT
)
return refuse(
'polar',
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
);
if (east === west || north === south)
return refuse('degenerate', 'Box must enclose an area');
const { width, height } = boxSideKm(normalized);
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
return { ok: true, box: normalized };
}
const formatKm = (km) => Math.round(Number(km) || 0).toLocaleString('en-US');
// One short line each: the panel's hint says what to do about it.
function tooLargeMessage(sideKm) {
return `Box is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/** The refusal for USE VIEW when the camera sees more than the cap allows. */
export function viewTooLargeMessage(sideKm) {
return `View is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/**
* The camera height, in metres, at which a top-down view is `FIT_VIEW_KM`
* wide: the horizontal half-angle is atan(tan(fovy / 2) · width / height).
* Clamped to 5–400 km; without a canvas or FOV, a square 60° view.
* @param {{ width?: number, height?: number, fovy?: number }} view Canvas size in px, vertical FOV in radians.
* @returns {number}
*/
export function fitViewHeightM({ width, height, fovy } = {}) {
const aspect = Number(width) / Number(height);
const valid =
Number.isFinite(aspect) && aspect > 0 && fovy > 0 && fovy < Math.PI;
const halfWidth = valid ? Math.tan(fovy / 2) * aspect : Math.tan(Math.PI / 6);
const km = FIT_VIEW_KM / (2 * halfWidth);
return Math.min(FIT_HEIGHT_KM.max, Math.max(FIT_HEIGHT_KM.min, km)) * 1000;
}
/**
* The centre of a box that does not cross the dateline, in degrees.
* @param {object} box Degrees box.
* @returns {{ lon: number, lat: number } | null}
*/
export function boxCentre(box) {
const normalized = normalizeBox(box);
if (!normalized) return null;
return {
lon: (normalized.west + normalized.east) / 2,
lat: (normalized.south + normalized.north) / 2,
};
}
/**
* A square box centred on a pin, `sideKm` on the ground (the east–west extent
* widens by 1/cos(lat)); null when the result would not validate.
* @param {number} lon Degrees.
* @param {number} lat Degrees.
* @param {number} [sideKm]
* @returns {object | null}
*/
export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
if (!finite(lon) || !finite(lat) || !finite(sideKm) || sideKm <= 0)
return null;
const cosLat = Math.cos(toRadians(lat));
if (cosLat <= 0) return null;
const halfLat = sideKm / 2 / KM_PER_DEG_LAT;
const halfLon = sideKm / 2 / (KM_PER_DEG_LAT * cosLat);
const clampLat = (value) =>
Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, value));
const result = validateBox({
west: lon - halfLon,
south: clampLat(lat - halfLat),
east: lon + halfLon,
north: clampLat(lat + halfLat),
});
return result.ok ? result.box : null;
}
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
});
}
/** Degrees box from a radians rectangle (Cesium `Rectangle` shape). */
export function boxFromRectangle(rectangle) {
return mapBox(rectangle, (radians) => (Number(radians) * 180) / Math.PI);
}
/** Degrees box → signed integers at degrees × 100000 (the share-link form). */
export function quantizeBox(box) {
return mapBox(normalizeBox(box), (value) => Math.round(value * QUANTUM));
}
/** Inverse of `quantizeBox`. */
export function dequantizeBox(ints) {
return mapBox(ints, (value) => Number(value) / QUANTUM);
}
function isValidDay(day) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
if (!match) return false;
const time = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
);
return (
Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day
);
}
/**
* UTC calendar day (`YYYY-MM-DD`) of an ISO timestamp, Date or day string;
* null when unparseable or not a real day.
* @param {string | number | Date} value
* @returns {string | null}
*/
export function utcDay(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
return isValidDay(value) ? value : null;
const time = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: null;
}
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
return match ? `${MONTHS[Number(match[1]) - 1]} ${Number(match[2])}` : '';
}
/** `17:12` (UTC) for an ISO timestamp; '' when unparseable. */
export function hhmm(iso) {
const time = Date.parse(iso);
if (!Number.isFinite(time)) return '';
const date = new Date(time);
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
}
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
export function candidateKey(product, day) {
return `${product}:${day}`;
}
/** Split a candidate key; null when the product is unknown or the day invalid. */
export function parseCandidateKey(key) {
const match = /^(S30|L30|VIIRS):(\d{4}-\d{2}-\d{2})$/.exec(String(key));
if (!match || !isValidDay(match[2])) return null;
return { product: match[1], day: match[2] };
}
const cloudOf = (granule) => (finite(granule?.cloud) ? granule.cloud : null);
function buildCandidate(product, day, granules) {
const clouds = granules.map(cloudOf).filter((value) => value !== null);
const starts = granules
.map((granule) => Date.parse(granule.timeStart))
.filter(Number.isFinite);
const ends = granules
.map((granule) => Date.parse(granule.timeEnd ?? granule.timeStart))
.filter(Number.isFinite);
return {
key: candidateKey(product, day),
product,
day,
granules,
cloud: clouds.length
? { min: Math.min(...clouds), max: Math.max(...clouds) }
: null,
timeRange:
starts.length && ends.length
? {
start: new Date(Math.min(...starts)).toISOString(),
end: new Date(Math.max(...ends)).toISOString(),
}
: null,
availability: granules.length ? 'present' : 'empty',
coverage: 'unknown',
};
}
/**
* Group granules into one candidate per product and UTC day of `timeStart`,
* newest first. Granules without a known product or start time are dropped.
* @param {Array<object>} granules
* @returns {Array<object>}
*/
export function groupGranulesByDay(granules) {
const buckets = new Map();
for (const granule of Array.isArray(granules) ? granules : []) {
const day = utcDay(granule?.timeStart);
if (!PRODUCTS[granule?.product] || !day) continue;
const key = candidateKey(granule.product, day);
if (!buckets.has(key))
buckets.set(key, { product: granule.product, day, granules: [] });
buckets.get(key).granules.push(granule);
}
return mergeCandidates([
[...buckets.values()].map(({ product, day, granules: list }) =>
buildCandidate(product, day, list),
),
]);
}
/**
* One VIIRS overview candidate per UTC day for the last `days` days, today
* first. Availability stays unknown until the thumbnail probe answers;
* coverage is full because the overview is a global mosaic.
* @param {string | Date} todayIso
* @param {number} [days]
* @returns {Array<object>}
*/
export function viirsCandidates(todayIso, days = CATALOG_DAYS) {
const today = utcDay(todayIso);
if (!today) return [];
const start = Date.parse(`${today}T00:00:00Z`);
const out = [];
for (let i = 0; i < Math.max(0, Math.trunc(days)); i += 1) {
const day = new Date(start - i * DAY_MS).toISOString().slice(0, 10);
out.push({
key: candidateKey('VIIRS', day),
product: 'VIIRS',
day,
granules: [],
cloud: null,
timeRange: null,
availability: 'unknown',
coverage: 'full',
});
}
return out;
}
/**
* Merge candidate lists newest day first, then S30, L30, VIIRS; the first
* occurrence of a key wins.
* @param {Array<Array<object>>} lists
* @returns {Array<object>}
*/
export function mergeCandidates(lists) {
const byKey = new Map();
for (const list of lists) {
for (const candidate of list || []) {
if (candidate?.key && !byKey.has(candidate.key))
byKey.set(candidate.key, candidate);
}
}
return [...byKey.values()].sort((a, b) =>
a.day !== b.day
? a.day < b.day
? 1
: -1
: PRODUCT_ORDER.indexOf(a.product) - PRODUCT_ORDER.indexOf(b.product),
);
}
function pointInPolygon(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const crosses =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi;
if (crosses) inside = !inside;
}
return inside;
}
/**
* How much of the box a candidate's granule footprints cover, sampled at the
* four corners and the centre: `full`, `partial`, or `unknown` without
* footprints.
* @param {object} candidate
* @param {object} box Degrees box.
* @returns {'full' | 'partial' | 'unknown'}
*/
export function coverageFor(candidate, box) {
const normalized = normalizeBox(box);
const footprints = (candidate?.granules || [])
.map((granule) => granule?.footprint)
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
if (!normalized || !footprints.length) return 'unknown';
const { west, south, east, north } = normalized;
const samples = [
[west, south],
[east, south],
[east, north],
[west, north],
[(west + east) / 2, (south + north) / 2],
];
return samples.every(([lon, lat]) =>
footprints.some((ring) => pointInPolygon(lon, lat, ring)),
)
? 'full'
: 'partial';
}
/**
* Pick the START HERE day. Coverage first: a present HLS day that covers the
* whole box (`full`, or `unknown` without footprints) beats a newer sliver at
* the box edge, which changes none of the frame when draped. Within a tier
* the newest day whose granules ALL have known cloud ≤ `maxCloud` wins
* (`clear`), else the newest (`cloudy`); only slivers left → `partial`; else
* the newest non-empty VIIRS day (`overview`). `certain` is false when the
* catalog was truncated.
* @param {Array<object>} candidates
* @param {{ maxCloud?: number, truncated?: boolean }} [options]
* @returns {{ candidate: object | null, reason: 'clear' | 'cloudy' | 'partial' | 'overview' | null, certain: boolean }}
*/
export function rankLatest(
candidates,
{ maxCloud = MAX_CLOUD_FOR_CLEAR, truncated = false } = {},
) {
const sorted = mergeCandidates([candidates]);
const certain = !truncated;
const present = (c) => c.availability !== 'empty';
const hlsDay = (c) =>
PRODUCTS[c.product] &&
!PRODUCTS[c.product].overview &&
present(c) &&
c.granules.length > 0;
const coversBox = (c) => c.coverage !== 'partial';
const clearSky = (c) =>
c.granules.every((granule) => {
const cloud = cloudOf(granule);
return cloud !== null && cloud <= maxCloud;
});
const tiers = [
[(c) => hlsDay(c) && coversBox(c) && clearSky(c), 'clear'],
[(c) => hlsDay(c) && coversBox(c), 'cloudy'],
[(c) => hlsDay(c) && clearSky(c), 'partial'],
[hlsDay, 'partial'],
[(c) => c.product === 'VIIRS' && present(c), 'overview'],
];
for (const [matches, reason] of tiers) {
const candidate = sorted.find(matches);
if (candidate) return { candidate, reason, certain };
}
return { candidate: null, reason: null, certain };
}
function daysAgoLabel(day, now) {
const today = utcDay(now);
if (!today) return null;
const delta = Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
if (delta <= 0) return 'today';
return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
if (!cloud) return 'cloud unknown';
const min = Math.round(cloud.min);
const max = Math.round(cloud.max);
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
* @returns {string}
*/
export function formatCandidateReadout(candidate, now = new Date()) {
const product = PRODUCTS[candidate?.product];
if (!product || !candidate?.day) return '';
const [y, m, d] = candidate.day.split('-').map(Number);
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
when +=
(candidate.granules?.length || 0) > 1 && end !== start
? ` ${start}–${end}Z`
: ` ${start}Z`;
}
const parts = [when];
const ago = daysAgoLabel(candidate.day, now);
if (ago) parts.push(ago);
parts.push(product.sensor, `${product.resolutionM} m`);
if (product.overview) parts.push('overview');
parts.push(cloudLabel(candidate.cloud));
return parts.join(' · ');
}
/**
* GIBS WMTS REST template with `{s}`, `{z}`, `{y}`, `{x}` placeholders (y
* precedes x in the GIBS path).
* @param {string} product
* @param {string} day `YYYY-MM-DD`
* @returns {string}
*/
export function gibsTemplate(product, day) {
const spec = PRODUCTS[product];
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
return `https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/${spec.gibsLayer}/default/${day}/GoogleMapsCompatible_Level${spec.maxLevel}/{z}/{y}/{x}.${spec.format}`;
}
/**
* Worldview Snapshots URL for a thumbnail or export. EPSG:4326 BBOX order is
* lat,lon: `south,west,north,east`.
* @param {{ product: string, day: string, box: object, width: number, height: number, format?: string }} request
* @returns {string}
*/
export function wvsSnapshotUrl({
product,
day,
box,
width,
height,
format = 'image/png',
}) {
const spec = PRODUCTS[product];
const normalized = normalizeBox(box);
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
if (!normalized) throw new TypeError('A finite box is required');
const bbox = [
normalized.south,
normalized.west,
normalized.north,
normalized.east,
]
.map((value) => String(Number(value.toFixed(6))))
.join(',');
return (
`${WVS_ENDPOINT}?REQUEST=GetSnapshot&LAYERS=${spec.gibsLayer}` +
`&CRS=EPSG:4326&TIME=${day}&BBOX=${bbox}` +
`&WIDTH=${Math.round(width)}&HEIGHT=${Math.round(height)}&FORMAT=${format}`
);
}
/**
* Thumbnail load order: the focused card (clamped into the visible range),
* the visible cards outward from it, then `extra` cards beyond each edge.
* @param {number} focusIndex
* @param {number} firstVisible
* @param {number} lastVisible
* @param {number} total
* @param {number} [extra]
* @returns {number[]}
*/
export function thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
total,
extra = 2,
) {
const count = Math.max(0, Math.trunc(total));
if (!count) return [];
const clamp = (value) => Math.max(0, Math.min(count - 1, Math.trunc(value)));
const first = clamp(Math.min(firstVisible, lastVisible));
const last = clamp(Math.max(firstVisible, lastVisible));
const focus = Math.max(first, Math.min(last, clamp(focusIndex)));
const order = [];
const push = (index) => {
if (index >= 0 && index < count && !order.includes(index))
order.push(index);
};
push(focus);
for (let step = 1; step <= last - first; step += 1) {
if (focus + step <= last) push(focus + step);
if (focus - step >= first) push(focus - step);
}
for (let step = 1; step <= Math.max(0, Math.trunc(extra)); step += 1) {
push(last + step);
push(first - step);
}
return order;
}
+439
View File
@@ -0,0 +1,439 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxFromPin,
boxFromRectangle,
boxSideKm,
coverageFor,
boxCentre,
dequantizeBox,
fitViewHeightM,
formatCandidateReadout,
gibsTemplate,
groupGranulesByDay,
mergeCandidates,
parseCandidateKey,
quantizeBox,
rankLatest,
shortDay,
thumbnailOrder,
utcDay,
validateBox,
viewTooLargeMessage,
viirsCandidates,
wvsSnapshotUrl,
} from './model.js';
import { BOX } from './testDoubles.mjs';
function granule(overrides = {}) {
return {
id: 'g',
product: 'S30',
timeStart: '2026-09-18T17:12:00.000Z',
timeEnd: '2026-09-18T17:12:30.000Z',
cloud: 12,
footprint: null,
...overrides,
};
}
function hls(product, day, clouds, extra = {}) {
const granules = clouds.map((cloud, index) =>
granule({
id: `${product}-${day}-${index}`,
product,
timeStart: `${day}T17:1${index}:00.000Z`,
timeEnd: `${day}T17:1${index}:30.000Z`,
cloud,
}),
);
return { ...groupGranulesByDay(granules)[0], ...extra };
}
test('validateBox refuses dateline, polar, degenerate, invalid and oversized boxes', () => {
const reason = (box) => validateBox(box).reason;
assert.equal(
reason({ west: 179, south: 0, east: -179, north: 1 }),
'dateline',
);
assert.equal(
reason({ west: 0, south: 84, east: 0.5, north: 85.05111 }),
'polar',
);
assert.equal(
reason({ west: 0, south: -85.0512, east: 0.5, north: -84 }),
'polar',
);
// Refusals fit the one-line notice.
assert.equal(
validateBox({ west: 0, south: 84, east: 0.5, north: 85.05111 }).message,
'Imagery stops at ±85.05° latitude',
);
assert.equal(
validateBox({ west: 179, south: 0, east: -179, north: 1 }).message,
'Select one side of the dateline',
);
assert.equal(
validateBox({ west: 0, south: 84.9, east: 0.05, north: 85 }).ok,
true,
);
assert.equal(
reason({ west: 10, south: 10, east: 10, north: 11 }),
'degenerate',
);
assert.equal(
reason({ west: Infinity, south: 0, east: 1, north: 1 }),
'invalid',
);
assert.equal(reason({ west: -181, south: 0, east: 1, north: 1 }), 'invalid');
assert.equal(reason(undefined), 'invalid');
// The cap is 1,000 km a side, and the refusal says how big the box is.
const tall = validateBox({ west: 0, south: 0, east: 0.1, north: 10 });
assert.equal(tall.reason, 'too-large');
assert.equal(tall.message, 'Box is 1,113 km wide · limit 1,000 km');
assert.equal(
reason({ west: 0, south: 0, east: 10, north: 0.1 }),
'too-large',
);
assert.equal(validateBox({ west: 0, south: 0, east: 8, north: 8 }).ok, true);
assert.equal(
viewTooLargeMessage(2400),
'View is 2,400 km wide · limit 1,000 km',
);
// Latitudes are ordered; longitudes are never swapped.
assert.deepEqual(
validateBox({ west: -97.8, south: 30.3, east: -97.7, north: 30.2 }).box,
BOX,
);
});
test('ZOOM IN: the height whose top-down view is 400 km wide, clamped to 5–400 km; a box centre', () => {
const near = (actual, expected, label) =>
assert.ok(Math.abs(actual - expected) < 1e-6, `${label}: ${actual}`);
// A 1000 × 500 canvas with Cesium's 60° horizontal FOV: tan(fovy / 2) is
// tan(30°) / 2, so the view is 2 · h · tan(30°) wide.
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2);
const height = fitViewHeightM({ width: 1000, height: 500, fovy });
near(height, 400_000 / (2 * Math.tan(Math.PI / 6)), 'wide canvas');
near(
2 * height * Math.tan(fovy / 2) * 2,
400_000,
'the view at that height is 400 km wide',
);
// A tall canvas would need more than 400 km; a very wide one less than 5.
assert.equal(
fitViewHeightM({ width: 200, height: 1000, fovy: Math.PI / 3 }),
400_000,
);
assert.equal(fitViewHeightM({ width: 1000, height: 500, fovy: 3.1 }), 5_000);
// No canvas or FOV: a square 60° view.
near(
fitViewHeightM({ width: 0, height: 0, fovy: undefined }),
400_000 / (2 * Math.tan(Math.PI / 6)),
'fallback',
);
assert.deepEqual(boxCentre({ west: 0, south: 0, east: 20, north: 10 }), {
lon: 10,
lat: 5,
});
assert.equal(boxCentre(null), null);
});
test('box width is the longitude span at the mid-latitude, never the shortest arc', () => {
const equator = boxSideKm({ west: 0, south: -0.5, east: 1, north: 0.5 });
assert.ok(Math.abs(equator.width - 111.32) < 1e-6);
assert.ok(Math.abs(equator.height - 111.32) < 1e-6);
const north = boxSideKm({ west: 0, south: 59.5, east: 1, north: 60.5 });
assert.ok(north.width > 55 && north.width < 56.5, String(north.width));
const nearlyGlobal = { west: -179.9, south: 30, east: 179.9, north: 30.1 };
assert.ok(boxSideKm(nearlyGlobal).width > 30000);
assert.equal(validateBox(nearlyGlobal).reason, 'too-large');
});
test('boxFromPin builds a 10 km square within 2% at any latitude and refuses what cannot validate', () => {
for (const [lon, lat] of [
[-97.75, 30.27],
[18.07, 59.33],
[0, 0],
[-70, -45],
]) {
const box = boxFromPin(lon, lat);
const { width, height } = boxSideKm(box);
assert.ok(Math.abs(width - 10) < 0.2, `width ${width} at ${lat}`);
assert.ok(Math.abs(height - 10) < 0.2, `height ${height} at ${lat}`);
assert.ok(Math.abs((box.west + box.east) / 2 - lon) < 1e-9);
}
assert.ok(Math.abs(boxSideKm(boxFromPin(10, 10, 50)).width - 50) < 1);
assert.equal(boxFromPin(NaN, 0), null);
assert.equal(boxFromPin(0, 0, 5000), null, 'over the cap');
assert.equal(boxFromPin(179.99, 0), null, 'would cross the dateline');
assert.equal(boxFromPin(0, 89), null, 'clamped to a degenerate polar box');
assert.equal(boxFromPin(0, 85.04).north, 85.0511, 'clamped, still valid');
});
test('rectangles convert from radians and the share-link quantization round-trips', () => {
const box = boxFromRectangle({
west: -Math.PI / 2,
south: 0,
east: -Math.PI / 4,
north: Math.PI / 6,
});
assert.ok(Math.abs(box.west + 90) < 1e-9 && Math.abs(box.north - 30) < 1e-9);
assert.equal(boxFromRectangle(null), null);
const ints = quantizeBox({
west: -97.812345678,
south: 30.2,
east: -97.7,
north: 30.3999999,
});
assert.deepEqual(ints, {
west: -9781235,
south: 3020000,
east: -9770000,
north: 3040000,
});
assert.deepEqual(quantizeBox(dequantizeBox(ints)), ints);
assert.equal(quantizeBox(null), null);
assert.equal(dequantizeBox({ west: 'x' }), null);
});
test('days and candidate keys are real UTC calendar days', () => {
assert.equal(utcDay('2026-09-18T20:00:00-05:00'), '2026-09-19');
assert.equal(utcDay(new Date(Date.UTC(2026, 0, 1))), '2026-01-01');
assert.equal(utcDay('2026-02-30'), null);
assert.equal(utcDay('nope'), null);
assert.deepEqual(parseCandidateKey('L30:2026-09-10'), {
product: 'L30',
day: '2026-09-10',
});
assert.equal(parseCandidateKey('X30:2026-09-10'), null);
assert.equal(parseCandidateKey('S30:2026-02-30'), null);
assert.equal(shortDay('2026-09-18'), 'Sep 18');
assert.equal(shortDay('nope'), '');
});
test('granules group into one candidate per product and UTC day, newest first', () => {
const candidates = groupGranulesByDay([
granule({ id: 'a', cloud: 12, timeEnd: '2026-09-18T17:12:40Z' }),
granule({
id: 'b',
cloud: 18,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:40Z',
}),
granule({
id: 'c',
cloud: null,
product: 'L30',
timeStart: '2026-09-18T16:00:00Z',
timeEnd: null,
}),
granule({ id: 'd', cloud: 3, timeStart: '2026-09-10T17:00:00Z' }),
granule({ id: 'e', timeStart: 'garbage' }),
granule({ id: 'f', product: 'nope' }),
]);
assert.deepEqual(
candidates.map((c) => c.key),
['S30:2026-09-18', 'L30:2026-09-18', 'S30:2026-09-10'],
);
const [s18, l18] = candidates;
assert.deepEqual(s18.cloud, { min: 12, max: 18 });
assert.deepEqual(s18.timeRange, {
start: '2026-09-18T17:12:00.000Z',
end: '2026-09-18T17:20:40.000Z',
});
assert.equal(s18.availability, 'present');
assert.equal(l18.cloud, null, 'missing cloud stays unknown');
assert.equal(l18.timeRange.end, '2026-09-18T16:00:00.000Z');
});
test('VIIRS lists the last 30 UTC days as unprobed full-coverage days', () => {
const days = viirsCandidates('2026-03-01T13:00:00Z', 3);
assert.deepEqual(
days.map((c) => c.key),
['VIIRS:2026-03-01', 'VIIRS:2026-02-28', 'VIIRS:2026-02-27'],
);
assert.equal(days[0].availability, 'unknown');
assert.equal(days[0].coverage, 'full');
assert.equal(viirsCandidates('2026-09-21').length, 30);
assert.deepEqual(viirsCandidates('bad'), []);
});
test('merging sorts newest day first, then S30, L30, VIIRS, and the first key wins', () => {
const merged = mergeCandidates([
viirsCandidates('2026-09-18', 2),
[hls('L30', '2026-09-18', [5]), hls('S30', '2026-09-17', [5])],
[hls('S30', '2026-09-18', [7]), hls('S30', '2026-09-18', [99])],
]);
assert.deepEqual(
merged.map((c) => c.key),
[
'S30:2026-09-18',
'L30:2026-09-18',
'VIIRS:2026-09-18',
'S30:2026-09-17',
'VIIRS:2026-09-17',
],
);
assert.deepEqual(merged[0].cloud, { min: 7, max: 7 });
});
test('coverage samples the box corners and centre against the union of footprints', () => {
const ring = (west, east) => [
[west, 30],
[east, 30],
[east, 31],
[west, 31],
];
const withRings = (...rings) => ({
granules: rings.map((footprint) => granule({ footprint })),
});
assert.equal(coverageFor(withRings(ring(-98, -97)), BOX), 'full');
assert.equal(coverageFor(withRings(ring(-98, -97.75)), BOX), 'partial');
assert.equal(
coverageFor(withRings(ring(-98, -97.75), ring(-97.75, -97)), BOX),
'full',
);
assert.equal(coverageFor(withRings(null), BOX), 'unknown');
});
test('START HERE is the newest all-clear HLS day; unknown cloud is never clear', () => {
const candidates = [
hls('S30', '2026-09-20', [10, null]),
hls('S30', '2026-09-19', [5, 21]),
hls('S30', '2026-09-18', [0, 20]),
];
assert.deepEqual(rankLatest([...candidates].reverse()), {
candidate: candidates[2],
reason: 'clear',
certain: true,
});
assert.equal(rankLatest(candidates, { truncated: true }).certain, false);
// Then the newest cloudy HLS day, then the newest non-empty VIIRS day.
const cloudy = rankLatest([
hls('L30', '2026-09-20', [45]),
hls('S30', '2026-09-19', [null]),
]);
assert.equal(cloudy.candidate.key, 'L30:2026-09-20');
assert.equal(cloudy.reason, 'cloudy');
const viirs = viirsCandidates('2026-09-21', 3);
viirs[0] = { ...viirs[0], availability: 'empty' };
const empty = { ...hls('S30', '2026-09-21', [0]), availability: 'empty' };
const overview = rankLatest([empty, ...viirs]);
assert.equal(overview.candidate.key, 'VIIRS:2026-09-20');
assert.equal(overview.reason, 'overview');
assert.equal(rankLatest([]).candidate, null);
});
test('START HERE prefers a day that covers the whole box over a newer sliver', () => {
const full = (day, clouds) => hls('S30', day, clouds, { coverage: 'full' });
const sliver = (day, clouds) =>
hls('S30', day, clouds, { coverage: 'partial' });
const pick = (list) => {
const { candidate, reason } = rankLatest(list);
return `${candidate?.day} ${reason}`;
};
assert.equal(
pick([sliver('2026-09-20', [0]), full('2026-09-18', [10])]),
'2026-09-18 clear',
);
assert.equal(
pick([full('2026-09-20', [null]), sliver('2026-09-19', [0])]),
'2026-09-20 cloudy',
);
// Unknown coverage (no footprints) counts as whole-box.
assert.equal(
pick([
hls('S30', '2026-09-20', [5], { coverage: 'unknown' }),
full('2026-09-18', [5]),
]),
'2026-09-20 clear',
);
// Only slivers: the newest clear one, then the newest at all.
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [0])]),
'2026-09-19 partial',
);
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [null])]),
'2026-09-20 partial',
);
assert.equal(
pick([...viirsCandidates('2026-09-21', 2), sliver('2026-09-19', [70])]),
'2026-09-19 partial',
'a sliver still beats the overview',
);
});
test('the readout is one UTC line with age, sensor, resolution and scene cloud', () => {
const now = '2026-09-21T12:00:00Z';
const readout = (granules) =>
formatCandidateReadout(groupGranulesByDay(granules)[0], now);
assert.equal(
readout([granule({ timeStart: '2026-09-18T17:12:10Z' })]),
'Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud',
);
assert.equal(
readout([
granule({ id: 'a' }),
granule({
id: 'b',
cloud: 18.4,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:30Z',
}),
]),
'Sep 18, 2026 17:12–17:20Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12–18% scene cloud',
);
assert.equal(
readout([
granule({
product: 'L30',
cloud: null,
timeStart: '2026-09-20T16:05:00Z',
timeEnd: null,
}),
]),
'Sep 20, 2026 16:05Z · yesterday · Landsat 8/9 via HLS · 30 m · cloud unknown',
);
assert.equal(
formatCandidateReadout(viirsCandidates('2026-09-21', 1)[0], now),
'Sep 21, 2026 · today · VIIRS NOAA-21 · 250 m · overview · cloud unknown',
);
assert.equal(formatCandidateReadout(null), '');
});
test('GIBS templates put y before x; Worldview snapshots take south,west,north,east', () => {
assert.equal(
gibsTemplate('S30', '2026-09-18'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/HLS_S30_Nadir_BRDF_Adjusted_Reflectance/default/2026-09-18/GoogleMapsCompatible_Level12/{z}/{y}/{x}.png',
);
assert.equal(
gibsTemplate('VIIRS', '2026-09-21'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA21_CorrectedReflectance_TrueColor/default/2026-09-21/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg',
);
assert.match(gibsTemplate('L30', '2026-09-10'), /HLS_L30_.*Level12.*\.png$/);
assert.throws(() => gibsTemplate('MODIS', '2026-01-01'), /Unknown/);
assert.equal(
wvsSnapshotUrl({
product: 'S30',
day: '2026-09-18',
box: BOX,
width: 256,
height: 256,
}),
'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&CRS=EPSG:4326&TIME=2026-09-18&BBOX=30.2,-97.8,30.3,-97.7&WIDTH=256&HEIGHT=256&FORMAT=image/png',
);
assert.throws(
() => wvsSnapshotUrl({ product: 'S30', day: 'x', box: null }),
TypeError,
);
});
test('thumbnails load the focused card, then the visible cards outward, then the margins', () => {
assert.deepEqual(thumbnailOrder(5, 3, 7, 20), [5, 6, 4, 7, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(19, 17, 19, 20), [19, 18, 17, 16, 15]);
assert.deepEqual(thumbnailOrder(50, 7, 3, 10), [7, 6, 5, 4, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(0, 0, 0, 0), []);
});
+265
View File
@@ -0,0 +1,265 @@
/**
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
* host the map stack offers. Against the basemap slot `a` splits left and
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
* host switch or a replaced image never strands one. Tile requests pass a
* small semaphore of our own; Cesium's global request limits stay untouched.
*/
import * as Cesium from 'cesium';
import { PRODUCTS, gibsTemplate } from './model.js';
const SLOT_IDS = ['a', 'b'];
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
/** Render reason for a deferred or settled tile request. */
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
/** Delay before the one frame that retries tiles Cesium itself deferred. */
export const TILE_RETRY_DELAY_MS = 250;
function clampAlpha(value) {
const alpha = Number(value);
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
}
const boxKey = (box) =>
[box.west, box.south, box.east, box.north].map(Number).join(',');
/**
* Gate a provider's `requestImage` through the semaphore. Returning
* `undefined` tells Cesium to retry on a later frame, but under
* `requestRenderMode` there is no later frame unless something asks — and a
* deferral must never ask for one itself: the retried tile would defer again
* and ask again, keeping the scene rendering every frame. So a request that
* finds our semaphore full asks for nothing (the request holding the slot
* asks for one frame when it settles), and a request the provider itself
* deferred goes through the one coalesced delayed retry.
*/
function admitTileRequests(provider, admission, requestRender, retry) {
const original = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (admission.inFlight >= admission.limit) return undefined;
admission.inFlight += 1;
const release = () => {
admission.inFlight -= 1;
};
let promise;
try {
promise = original(x, y, level, request);
} catch (error) {
release();
throw error;
}
if (!promise) {
release();
retry.schedule();
return undefined;
}
return Promise.resolve(promise).finally(() => {
release();
requestRender(TILE_RENDER_REASON);
});
};
return provider;
}
/**
* Create the renderer.
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
*/
export function createRecentImageryRenderer({
cesium = Cesium,
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
} = {}) {
let host = NO_HOST;
let destroyed = false;
const admission = { limit: maxTileRequests, inFlight: 0 };
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
const owned = { a: null, b: null };
// One timer for both slots: any number of upstream deferrals while it is
// armed share the same single retry frame.
let retryTimer = null;
const retry = {
schedule() {
if (retryTimer !== null) return;
retryTimer = setTimeoutImpl(() => {
retryTimer = null;
if (!destroyed) requestRender(TILE_RENDER_REASON);
}, TILE_RETRY_DELAY_MS);
},
cancel() {
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
retryTimer = null;
},
};
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
tilingScheme: new cesium.WebMercatorTilingScheme(),
rectangle: cesium.Rectangle.fromDegrees(
box.west,
box.south,
box.east,
box.north,
),
credit: 'NASA GIBS',
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
}
function removeOwned(slotId) {
const record = owned[slotId];
if (!record) return false;
owned[slotId] = null;
try {
record.collection.remove(record.layer, true);
} catch {
/* the collection is already gone */
}
return true;
}
function applyLook(record) {
record.layer.alpha = record.alpha;
record.layer.splitDirection =
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
}
function mount(slotId, { candidate, box, alpha, split }) {
const layer = host.collection.addImageryProvider(
createProvider(candidate, box),
);
owned[slotId] = {
layer,
collection: host.collection,
candidate,
box,
alpha,
split,
};
applyLook(owned[slotId]);
}
return {
/**
* Drape a candidate into a slot. The same candidate on the same host only
* updates its look; anything else replaces the slot's layer.
* @param {'a'|'b'} slotId
* @param {object} candidate
* @param {object} box Degrees box.
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
* @returns {boolean} Whether a layer is now draped for the slot.
*/
showSlot(
slotId,
candidate,
box,
{ alpha = 1, splitDirection = 'none' } = {},
) {
if (destroyed || !candidate?.key || !box) return false;
if (!host.collection) {
removeOwned(slotId);
return false;
}
const current = owned[slotId];
const look = { alpha: clampAlpha(alpha), split: splitDirection };
if (
current &&
current.collection === host.collection &&
current.candidate.key === candidate.key &&
boxKey(current.box) === boxKey(box)
) {
if (current.alpha !== look.alpha || current.split !== look.split) {
Object.assign(current, look);
applyLook(current);
requestRender('recent-imagery-look');
}
return true;
}
removeOwned(slotId);
mount(slotId, { candidate, box, ...look });
requestRender('recent-imagery-show');
return true;
},
/** @param {'a'|'b'} slotId */
hideSlot(slotId) {
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
},
/**
* @param {'a'|'b'} slotId
* @param {number} alpha 0–1
*/
setAlpha(slotId, alpha) {
const record = owned[slotId];
const next = clampAlpha(alpha);
if (!record || record.alpha === next) return;
record.alpha = next;
applyLook(record);
requestRender('recent-imagery-alpha');
},
/**
* Move every owned layer to a new host. Layers are rebuilt there, never
* re-parented, so the old collection is left as it was found.
* @param {{ collection: object | null, kind: string }} nextHost
*/
rebind(nextHost) {
host = nextHost || NO_HOST;
let changed = false;
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
if (!record || record.collection === host.collection) continue;
removeOwned(slotId);
changed = true;
if (host.collection && !destroyed) mount(slotId, record);
}
if (changed) requestRender('recent-imagery-rebind');
},
/** What is draped right now, per slot. */
getOwned() {
const out = {};
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
out[slotId] = record
? {
key: record.candidate.key,
kind: host.kind,
alpha: record.alpha,
split: record.split,
}
: null;
}
return out;
},
/** Number of layers this renderer owns (never above two). */
ownedCount() {
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
},
/** Remove everything and refuse further work. */
destroy() {
if (destroyed) return;
destroyed = true;
retry.cancel();
let changed = false;
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
host = NO_HOST;
if (changed) requestRender('recent-imagery-destroy');
},
};
}
+210
View File
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TILE_RENDER_REASON,
TILE_RETRY_DELAY_MS,
createRecentImageryRenderer,
} from './rendering.js';
import { BOX, manualTimers } from './testDoubles.mjs';
/** A provider whose tile requests stay pending until the test settles them. */
class UrlTemplateImageryProvider {
constructor(options) {
this.options = options;
this.calls = [];
this.pending = [];
}
requestImage(x, y, level) {
this.calls.push([x, y, level]);
return new Promise((resolve) => this.pending.push(resolve));
}
}
function fakeCesium(requestImage) {
class Provider extends UrlTemplateImageryProvider {}
if (requestImage) Provider.prototype.requestImage = requestImage;
return {
UrlTemplateImageryProvider: Provider,
WebMercatorTilingScheme: class {},
Rectangle: {
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
},
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
};
}
function fakeCollection() {
const layers = [];
return {
layers,
addImageryProvider(provider) {
const layer = { provider, alpha: 1, splitDirection: 0 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
layer.destroyed = destroy;
},
};
}
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
const globe = fakeCollection();
renderer.rebind({ collection: globe, kind: 'globe' });
const tileFrames = () =>
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
return { renderer, globe, renders, timers, tileFrames };
}
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
const { renderer, globe, renders } = fixture();
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
const [layer] = globe.layers;
assert.match(
layer.provider.options.url,
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
);
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
assert.equal(layer.provider.options.credit, 'NASA GIBS');
assert.deepEqual(layer.provider.options.rectangle, BOX);
assert.equal(layer.alpha, 0.8);
assert.deepEqual(renders, ['recent-imagery-show']);
assert.deepEqual(renderer.getOwned().a, {
key: 'S30:2026-09-18',
kind: 'globe',
alpha: 0.8,
split: 'none',
});
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
assert.equal(layer.splitDirection, -1);
// A different day replaces the layer in its own collection.
renderer.showSlot('a', L30, BOX);
assert.equal(layer.destroyed, true);
assert.equal(globe.layers.length, 1);
// VIIRS is a level-9 JPEG overview with no alpha channel.
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
assert.equal(globe.layers[1].splitDirection, 1);
renderer.setAlpha('b', 7);
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
});
test('rapid replacement never owns more than two layers', () => {
const { renderer, globe } = fixture();
const days = [S30, L30, VIIRS];
for (let i = 0; i < 10; i += 1) {
renderer.showSlot('a', days[i % 3], BOX);
renderer.showSlot('b', days[(i + 1) % 3], BOX);
if (i % 4 === 0) renderer.hideSlot('a');
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
}
renderer.hideSlot('a');
renderer.hideSlot('b');
assert.equal(globe.layers.length, 0);
});
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
const { renderer, globe } = fixture();
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
const tileset = fakeCollection();
renderer.rebind({ collection: tileset, kind: 'tileset' });
assert.equal(globe.layers.length, 0);
assert.deepEqual(
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
[
[0.6, -1],
[1, 1],
],
);
assert.equal(renderer.getOwned().a.kind, 'tileset');
renderer.rebind({ collection: null, kind: 'none' });
assert.equal(tileset.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false);
renderer.rebind({ collection: globe, kind: 'globe' });
renderer.showSlot('a', S30, BOX);
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
});
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
});
renderer.showSlot('a', S30, BOX);
const { provider } = globe.layers[0];
const first = provider.requestImage(0, 0, 1);
assert.ok(first instanceof Promise);
for (let i = 1; i <= 120; i += 1)
assert.equal(provider.requestImage(i, 0, 1), undefined);
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
assert.equal(timers.armed(), 0);
provider.pending[0]('tile');
await first;
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
});
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
let throwNext = false;
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
requestImage() {
if (throwNext) throw new Error('boom');
return undefined;
},
});
renderer.showSlot('a', S30, BOX);
renderer.showSlot('b', L30, BOX);
for (const { provider } of globe.layers)
for (let i = 0; i < 40; i += 1)
assert.equal(provider.requestImage(i, 0, 0), undefined);
// Every deferral released its slot (the limit is one), and none asked
// for a frame of its own: one timer holds the single retry.
assert.equal(tileFrames(), 0);
assert.equal(timers.armed(), 1);
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
timers.flush();
assert.equal(tileFrames(), 1);
// A throwing provider releases its slot too.
throwNext = true;
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
throwNext = false;
globe.layers[0].provider.requestImage(0, 0, 0);
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
renderer.destroy();
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
assert.equal(tileFrames(), 1);
});
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
const { renderer, globe, renders } = fixture();
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
renderer.hideSlot('b');
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
assert.equal(globe.layers[0].splitDirection, -1);
assert.equal(renderer.getOwned().b, null);
const [layer] = globe.layers;
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
+368
View File
@@ -0,0 +1,368 @@
/**
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
* responses, and the Cesium/viewer/document fakes the box tool needs.
*/
import { thumbnailOrder } from './model.js';
export const BOX = Object.freeze({
west: -97.8,
south: 30.2,
east: -97.7,
north: 30.3,
});
export const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Timers that fire only when the test says so. */
export function manualTimers() {
const pending = new Map();
const cleared = [];
let nextId = 1;
return {
pending,
cleared,
setTimeoutImpl(fn, ms) {
const id = nextId++;
pending.set(id, { fn, ms });
return id;
},
clearTimeoutImpl(id) {
cleared.push(id);
pending.delete(id);
},
/** Fire every armed timer, in order. */
flush() {
const entries = [...pending.values()];
pending.clear();
for (const entry of entries) entry.fn();
},
armed: () => pending.size,
};
}
/** A `fetch` response with case-insensitive headers. */
export function response({
ok = true,
status = ok ? 200 : 500,
json = {},
headers = {},
blob = { size: 1 },
} = {}) {
const lower = new Map(
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
);
return {
ok,
status,
headers: { get: (name) => lower.get(name.toLowerCase()) ?? null },
json: async () => json,
blob: async () => blob,
};
}
const EVENT_TYPES = {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
LEFT_DOUBLE_CLICK: 'dblclick',
};
/**
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
*/
export function boxToolFakes() {
const handlers = [];
class ScreenSpaceEventHandler {
constructor(canvas) {
this.canvas = canvas;
this.actions = new Map();
this.destroyed = false;
handlers.push(this);
}
setInputAction(fn, type) {
this.actions.set(type, fn);
}
getInputAction(type) {
return this.actions.get(type);
}
removeInputAction(type) {
this.actions.delete(type);
}
destroy() {
this.destroyed = true;
}
}
class CustomDataSource {
constructor(name) {
this.name = name;
const values = [];
this.entities = {
values,
add: (entity) => (values.push(entity), entity),
removeAll: () => values.splice(0),
};
}
}
const color = { withAlpha: (alpha) => ({ alpha }) };
const cesium = {
ScreenSpaceEventHandler,
ScreenSpaceEventType: EVENT_TYPES,
CustomDataSource,
CallbackProperty: class {},
PolylineDashMaterialProperty: class {},
ClassificationType: { BOTH: 'both' },
Color: { fromCssColorString: () => color },
Rectangle: { fromDegrees: () => ({}) },
Cartesian3: { fromDegreesArray: (values) => values },
};
const stock = new ScreenSpaceEventHandler('stock');
const originalClick = () => 'select';
const originalDouble = () => 'track';
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
const sources = [];
const viewer = {
renders: 0,
scene: {
canvas: { clientWidth: 1000, clientHeight: 500 },
screenSpaceCameraController: { enableInputs: true },
requestRender: () => {
viewer.renders += 1;
},
},
screenSpaceEventHandler: stock,
dataSources: {
sources,
add: (source) => (sources.push(source), Promise.resolve(source)),
remove: (source) => sources.splice(sources.indexOf(source), 1),
},
};
const listeners = [];
const classes = new Set();
const documentRef = {
listeners,
classes,
addEventListener: (type, fn) => listeners.push([type, fn]),
removeEventListener: (type, fn) => {
const index = listeners.findIndex((entry) => entry[1] === fn);
if (index >= 0) listeners.splice(index, 1);
},
body: {
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
},
};
const pickWorld = (_viewer, nx, ny) =>
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
/** The live (non-stock) canvas handler, if the tool has one bound. */
const handler = () =>
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
const fire = (type, event) =>
handler()?.actions.get(EVENT_TYPES[type])?.(event);
/** Dispatch a keydown to the tool's document listener. */
const key = (name) => {
const event = {
key: name,
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopImmediatePropagation() {
this.stopped = true;
},
};
listeners.find(([type]) => type === 'keydown')?.[1](event);
return event;
};
return {
cesium,
viewer,
documentRef,
pickWorld,
handler,
fire,
key,
types: EVENT_TYPES,
originalClick,
originalDouble,
};
}
// ---- the layer's injected parts --------------------------------------
export function candidate(product, day, cloud = 5) {
const granules =
product === 'VIIRS'
? []
: [
{
id: `${product}-${day}`,
product,
timeStart: `${day}T17:12:00Z`,
timeEnd: `${day}T17:14:00Z`,
cloud,
},
];
return {
key: `${product}:${day}`,
product,
day,
granules,
cloud: granules.length ? { min: cloud, max: cloud } : null,
timeRange: granules.length
? { start: granules[0].timeStart, end: granules[0].timeEnd }
: null,
availability: granules.length ? 'present' : 'unknown',
coverage: 'full',
};
}
// Strip order, newest first: V21 (unprobed), S18 (clear), L16 (cloudy), V15.
export const CANDIDATES = Object.freeze([
candidate('VIIRS', '2026-09-21'),
candidate('S30', '2026-09-18', 12),
candidate('L30', '2026-09-16', 60),
candidate('VIIRS', '2026-09-15'),
]);
export function fakeRenderer() {
const calls = [];
const owned = { a: null, b: null };
let host = { kind: 'none' };
let peak = 0;
return {
calls,
rebind(next) {
calls.push(['rebind', next.kind]);
host = next;
for (const slot of ['a', 'b'])
if (owned[slot]) owned[slot] = { ...owned[slot], kind: next.kind };
},
showSlot(slot, cand, box, look) {
if (owned[slot]?.key !== cand.key) calls.push(['show', slot, cand.key]);
owned[slot] = { key: cand.key, kind: host.kind, ...look };
peak = Math.max(peak, this.ownedCount());
return true;
},
hideSlot(slot) {
owned[slot] = null;
},
setAlpha(slot, alpha) {
calls.push(['alpha', slot, alpha]);
},
getOwned: () => ({ ...owned }),
ownedCount: () => ['a', 'b'].filter((slot) => owned[slot]).length,
peak: () => peak,
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeThumbnails() {
const calls = [];
const listeners = new Set();
const statuses = new Map();
/** Every key the layer asked to probe, in the order it asked. */
const requested = [];
/** Direct `request` calls: `[key, priority]`. */
const requests = [];
const ask = (key) => {
if (!requested.includes(key)) requested.push(key);
};
return {
calls,
requested,
requests,
requestOrdered(candidates, box, window) {
calls.push(['ordered', candidates.map((c) => c.key), window]);
const { focusIndex, firstVisible, lastVisible, extra } = window;
thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
).forEach((index) => ask(candidates[index].key));
},
request(candidate, box, priority) {
ask(candidate.key);
requests.push([candidate.key, priority]);
},
/** Settle every requested key still unknown, as the loader would. */
probeRequested(status = 'present') {
for (const key of requested)
if (!statuses.has(key)) this.probe(key, status);
},
clear() {
calls.push(['clear']);
},
get: (key) => ({ status: statuses.get(key) || 'unknown' }),
/** Record a probe result and tell the layer, as the loader does. */
probe(key, status) {
statuses.set(key, status);
for (const listener of listeners) listener(key);
},
/** Record a probe result without telling anyone yet. */
setStatus: (key, status) => statuses.set(key, status),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeCatalog() {
const searches = [];
return {
searches,
searchHls(request) {
return new Promise((resolve, reject) => {
request.signal?.addEventListener('abort', () =>
reject(new Error('aborted')),
);
searches.push({ request, resolve, reject });
});
},
resolveLast(candidates = CANDIDATES) {
searches.at(-1).resolve({ candidates, truncated: false, errors: [] });
},
};
}
/** A map controller whose lease settles when the test says so. */
export function fakeController({ refuse = false } = {}) {
const calls = [];
const controller = {
calls,
acquireImageryComparison(options) {
calls.push(['acquire', options.owner, options.switchPolicy]);
if (refuse) throw new Error('held by bhote');
let settle;
const ready = new Promise((resolve) => {
settle = resolve;
});
controller.lease = {
ready,
release: () => {
calls.push(['release']);
return Promise.resolve();
},
settle: (result = { status: 'ready' }) => settle(result),
};
return controller.lease;
},
};
return controller;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
* candidate day. The response's `Data-Present` header is the only proof that
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
* are decoded images: residency is least-recently-USED, where a request or a
* completed decode is a use and a `get` read never is (the panel reads every
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
const EMPTY = Object.freeze({
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
});
/**
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
size = 256,
} = {}) {
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
const entries = new Map();
const listeners = new Set();
const queue = [];
let inFlight = 0;
let touchCounter = 0;
let destroyed = false;
function notify(key) {
for (const listener of [...listeners]) {
try {
listener(key);
} catch (error) {
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
}
}
}
function touch(entry) {
touchCounter += 1;
entry.touched = touchCounter;
}
function revokeImage(entry) {
if (!entry.objectUrl) return;
try {
revokeObjectUrl(entry.objectUrl);
} catch {
/* a revoked URL is already gone */
}
entry.objectUrl = null;
}
function evictDecoded(keep) {
const decoded = [...entries.values()]
.filter((entry) => entry.objectUrl && entry !== keep)
.sort((a, b) => a.touched - b.touched);
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
for (const entry of decoded) {
if (count <= resident) break;
revokeImage(entry);
notify(entry.key);
count -= 1;
}
}
function enqueue(entry) {
if (!queue.includes(entry)) queue.push(entry);
pump();
}
async function run(entry) {
const controller = new AbortController();
entry.controller = controller;
entry.loading = true;
inFlight += 1;
let status = 'error';
let objectUrl = null;
let acquisitionTime = null;
let cancelled = false;
try {
const url = wvsSnapshotUrl({
product: entry.candidate.product,
day: entry.candidate.day,
box: entry.box,
width: size,
height: size,
});
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
const present = String(
response.headers?.get?.('Data-Present') ?? 'true',
).toLowerCase();
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
if (present === 'false') {
status = 'empty';
} else {
const blob = await response.blob();
if (controller.signal.aborted) cancelled = true;
else {
objectUrl = createObjectUrl(blob);
status = 'present';
}
}
}
} catch (error) {
if (controller.signal.aborted || error?.name === 'AbortError')
cancelled = true;
else status = 'error';
} finally {
inFlight -= 1;
entry.loading = false;
entry.controller = null;
}
// Every settlement frees a slot, so a cancelled fetch pumps too.
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
if (objectUrl) revokeObjectUrl(objectUrl);
pump();
return;
}
// A failed re-fetch of an evicted image keeps what the day proved.
if (status !== 'error' || entry.status !== 'present') {
entry.status = status;
entry.acquisitionTime = acquisitionTime;
}
entry.objectUrl = objectUrl;
touch(entry);
if (objectUrl) evictDecoded(entry);
notify(entry.key);
pump();
}
function pump() {
if (destroyed) return;
queue.sort((a, b) => a.priority - b.priority);
while (inFlight < limit && queue.length) {
const entry = queue.shift();
if (entries.get(entry.key) !== entry) continue;
void run(entry);
}
}
return {
/**
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
* already known is only touched (and re-prioritised while it waits); a
* known-present day whose image was evicted is fetched again.
* @param {object} candidate
* @param {object} box Degrees box.
* @param {number} [priority]
*/
request(candidate, box, priority = 0) {
if (destroyed || !candidate?.key || !box) return;
const key = candidate.key;
const existing = entries.get(key);
if (existing) {
touch(existing);
if (existing.loading) return;
if (existing.status === 'unknown') {
existing.priority = priority;
} else if (existing.status === 'present' && !existing.objectUrl) {
existing.priority = priority;
enqueue(existing);
}
return;
}
const entry = {
key,
candidate,
box,
priority,
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
controller: null,
touched: 0,
};
touch(entry);
entries.set(key, entry);
enqueue(entry);
},
/**
* Request the visible window of a strip in `thumbnailOrder` priority.
* @param {Array<object>} candidates
* @param {object} box
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
*/
requestOrdered(
candidates,
box,
{ focusIndex, firstVisible, lastVisible, extra },
) {
const order = thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
);
order.forEach((index, priority) => {
this.request(candidates[index], box, priority);
});
},
/**
* Abort every fetch in flight and forget every unsettled request. A
* known day whose image was being fetched again keeps what it knows.
*/
cancelAll() {
queue.length = 0;
for (const entry of [...entries.values()]) {
if (entry.loading) entry.controller?.abort();
if (entry.status === 'unknown') entries.delete(entry.key);
}
},
/** Cancel everything and revoke every decoded thumbnail. */
clear() {
this.cancelAll();
for (const entry of entries.values()) revokeImage(entry);
entries.clear();
},
/**
* Read a day's state. A read is not a use: it never changes residency.
* @param {string} key Candidate key.
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
*/
get(key) {
const entry = entries.get(key);
if (!entry) return EMPTY;
return {
status: entry.status,
objectUrl: entry.objectUrl,
acquisitionTime: entry.acquisitionTime,
loading: entry.loading,
};
},
/** @param {(key: string) => void} listener */
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
stats() {
return {
inFlight,
queued: queue.length,
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
.length,
tracked: entries.size,
};
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
listeners.clear();
},
};
}
@@ -0,0 +1,317 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createThumbnailLoader } from './thumbnails.js';
import { BOX, response, settle } from './testDoubles.mjs';
const candidate = (product, day) => ({
key: `${product}:${day}`,
product,
day,
});
/** A fetch whose responses the test releases, in any order. */
function fakeFetch() {
const calls = [];
const impl = (url, { signal } = {}) =>
new Promise((resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
calls.push({ url, signal, resolve });
});
const respond = (index, { ok = true, present = 'true' } = {}) =>
calls[index].resolve(
response({
ok,
headers: {
'Data-Present': present,
'Acquisition-Time': '2026-09-18T17:12:00Z',
},
}),
);
return { impl, calls, respond };
}
function fixture(options = {}) {
const fetch = fakeFetch();
const created = [];
const revoked = [];
const loader = createThumbnailLoader({
fetchImpl: fetch.impl,
createObjectUrl: () => {
created.push(`blob:${created.length + 1}`);
return created.at(-1);
},
revokeObjectUrl: (url) => revoked.push(url),
...options,
});
return { loader, fetch, created, revoked };
}
test('Data-Present decides the day: present with an object URL and sensing time, empty, or error', async () => {
const { loader, fetch, created } = fixture();
const changes = [];
loader.subscribe((key) => changes.push(key));
loader.request(candidate('S30', '2026-09-18'), BOX, 0);
loader.request(candidate('L30', '2026-09-16'), BOX, 1);
loader.request(candidate('VIIRS', '2026-09-15'), BOX, 2);
assert.equal(loader.get('S30:2026-09-18').loading, true);
assert.match(
fetch.calls[0].url,
/wvs\.earthdata\.nasa\.gov.*HLS_S30.*TIME=2026-09-18.*WIDTH=256&HEIGHT=256/,
);
fetch.respond(0);
fetch.respond(1, { present: 'false' });
fetch.respond(2, { ok: false });
await settle();
assert.deepEqual(loader.get('S30:2026-09-18'), {
status: 'present',
objectUrl: 'blob:1',
acquisitionTime: '2026-09-18T17:12:00Z',
loading: false,
});
assert.equal(loader.get('L30:2026-09-16').status, 'empty');
assert.equal(loader.get('VIIRS:2026-09-15').status, 'error');
assert.deepEqual(created, ['blob:1']);
assert.deepEqual(changes.sort(), [
'L30:2026-09-16',
'S30:2026-09-18',
'VIIRS:2026-09-15',
]);
assert.equal(loader.get('nope').status, 'unknown');
});
test('at most maxInFlight fetches run and the queue drains by priority', async () => {
const { loader, fetch } = fixture({ maxInFlight: 2 });
loader.request(candidate('S30', '2026-09-10'), BOX, 5);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-13'), BOX, 3);
// The pump is eager: the first two arrivals start at once, the rest queue.
assert.equal(fetch.calls.length, 2);
assert.match(fetch.calls[0].url, /TIME=2026-09-10/);
assert.match(fetch.calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 2,
queued: 2,
decoded: 0,
tracked: 4,
});
fetch.respond(0);
await settle();
assert.equal(fetch.calls.length, 3);
assert.match(fetch.calls[2].url, /TIME=2026-09-12/, 'priority 0 before 3');
// A repeat request for a queued key just re-prioritises it.
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(loader.stats().tracked, 4);
fetch.respond(1);
await settle();
assert.equal(fetch.calls.length, 4);
assert.match(fetch.calls[3].url, /TIME=2026-09-13/);
});
test('requestOrdered loads the focused card first, then outward, then the margins', () => {
const { loader, fetch } = fixture({ maxInFlight: 10 });
const candidates = Array.from({ length: 8 }, (_, i) =>
candidate('S30', `2026-09-${String(10 + i).padStart(2, '0')}`),
);
loader.requestOrdered(candidates, BOX, {
focusIndex: 3,
firstVisible: 2,
lastVisible: 5,
extra: 1,
});
const days = fetch.calls.map(
(call) => /TIME=2026-09-(\d\d)/.exec(call.url)[1],
);
assert.deepEqual(days, ['13', '14', '12', '15', '16', '11']);
});
test('decoded thumbnails are evicted least-recently-used and revoked; a read is not a use and an evicted day keeps what it learned', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 10,
maxDecoded: 2,
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 2);
fetch.respond(0);
fetch.respond(1);
await settle();
assert.equal(loader.stats().decoded, 2);
loader.get('S30:2026-09-10'); // reading the oldest does not make it recent
fetch.respond(2);
await settle();
assert.equal(loader.stats().decoded, 2);
assert.deepEqual(revoked, ['blob:1']);
const evicted = loader.get('S30:2026-09-10');
assert.equal(evicted.status, 'present', 'availability survives eviction');
assert.equal(evicted.objectUrl, null);
assert.equal(evicted.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(loader.stats().tracked, 3, 'the entry is kept');
assert.equal(loader.get('S30:2026-09-11').objectUrl, 'blob:2');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A repeat request IS a use, and re-fetches an evicted day without ever
// reporting it unknown; 11 is now the LRU and goes when 10 comes back.
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4, 'an evicted day is fetched again');
assert.equal(loader.get('S30:2026-09-10').status, 'present');
assert.equal(loader.get('S30:2026-09-10').loading, true);
fetch.respond(3);
await settle();
assert.equal(loader.get('S30:2026-09-10').objectUrl, 'blob:4');
assert.deepEqual(revoked, ['blob:1', 'blob:2']);
assert.equal(loader.get('S30:2026-09-11').status, 'present');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A day that already holds an image is not fetched twice.
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4);
});
test('reading every card in strip order (a snapshot) never decides who is evicted; evicted days stay known and come back on request', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 20,
maxDecoded: 9,
});
const candidates = Array.from({ length: 20 }, (_, i) =>
candidate('S30', `2026-09-${String(i + 1).padStart(2, '0')}`),
);
candidates.forEach((c, i) => loader.request(c, BOX, i));
assert.equal(fetch.calls.length, 20);
// Decode the strip back to front, leaving the newest card in flight.
for (let i = 19; i >= 1; i -= 1) fetch.respond(i);
await settle();
const holders = () =>
candidates.filter((c) => loader.get(c.key).objectUrl).map((c) => c.key);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
'the nine most recently decoded',
);
// Sweep every card in strip order, several times, as getSnapshot does.
for (let pass = 0; pass < 3; pass += 1)
for (const c of candidates) loader.get(c.key);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
);
// The last decode evicts the least recently DECODED card, not the first
// card the sweep happened to read.
fetch.respond(0);
await settle();
assert.deepEqual(
holders(),
candidates.slice(0, 9).map((c) => c.key),
);
assert.equal(revoked.length, 11);
for (const c of candidates) {
const entry = loader.get(c.key);
assert.equal(entry.status, 'present', `${c.key} stays present`);
assert.equal(entry.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(entry.loading, false);
}
// Re-requesting an evicted day fetches it again and it holds an image.
const evictedKey = candidates[15].key;
assert.equal(loader.get(evictedKey).objectUrl, null);
loader.request(candidates[15], BOX, 0);
assert.equal(fetch.calls.length, 21);
assert.equal(loader.get(evictedKey).status, 'present');
fetch.respond(20);
await settle();
assert.ok(loader.get(evictedKey).objectUrl);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
});
test('cancelAll aborts in-flight fetches and a late settle never leaks an object URL', async () => {
const { loader, fetch, created, revoked } = fixture({ maxInFlight: 1 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
const signal = fetch.calls[0].signal;
loader.cancelAll();
assert.equal(signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 0,
});
await settle();
assert.equal(loader.stats().inFlight, 0);
assert.deepEqual(created, []);
assert.deepEqual(revoked, []);
assert.equal(loader.get('S30:2026-09-10').status, 'unknown');
});
test('clear and destroy revoke every resident thumbnail', async () => {
const { loader, fetch, revoked } = fixture({ maxInFlight: 10 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
fetch.respond(0);
fetch.respond(1);
await settle();
loader.clear();
assert.deepEqual(revoked.sort(), ['blob:1', 'blob:2']);
assert.equal(loader.stats().tracked, 0);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
fetch.respond(2);
await settle();
loader.destroy();
assert.equal(revoked.length, 3);
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(fetch.calls.length, 3, 'a destroyed loader fetches nothing');
});
test('a request queued behind an aborted fetch still starts once the abort settles', async () => {
// A response that arrives after the abort (the fetch ignored the signal).
const calls = [];
const fetchImpl = (url, { signal } = {}) =>
new Promise((resolve) => calls.push({ url, signal, resolve }));
const loader = createThumbnailLoader({
fetchImpl,
maxInFlight: 1,
createObjectUrl: () => 'blob:x',
revokeObjectUrl: () => {},
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.clear();
loader.request(candidate('S30', '2026-09-11'), BOX, 0);
assert.equal(calls[0].signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 1,
decoded: 0,
tracked: 1,
});
calls[0].resolve({
ok: true,
headers: { get: () => null },
blob: async () => ({}),
});
await settle();
assert.equal(calls.length, 2, 'the replacement request was pumped');
assert.match(calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
// The same sequence when the fetch rejects with AbortError instead.
const rejecting = fixture({ maxInFlight: 1 });
rejecting.loader.request(candidate('S30', '2026-09-10'), BOX, 0);
rejecting.loader.clear();
rejecting.loader.request(candidate('S30', '2026-09-11'), BOX, 0);
await settle();
assert.equal(rejecting.fetch.calls.length, 2);
assert.deepEqual(rejecting.loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
});
+5
View File
@@ -8,6 +8,7 @@ import { createTesting } from './testing.js';
import { createInteraction } from './interaction.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createRecords } from './records.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
@@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) {
parts.catalog = createCatalog(context);
parts.labels = createLabels(context);
parts.orbits = createOrbits(context);
parts.records = createRecords(context);
parts.rendering = createRendering(context);
parts.tracking = createTracking(context);
parts.testing = createTesting(context);
@@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) {
applySatellitePointFocusDeemphasis:
parts.rendering.applySatellitePointFocusDeemphasis,
getNextIssPass: parts.orbits.getNextIssPass,
getNextSatellitePass: parts.orbits.getNextSatellitePass,
resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass,
getAnalystRecords: parts.records.getAnalystRecords,
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
+84 -6
View File
@@ -7,7 +7,7 @@ import {
degreesLat,
twoline2satrec,
} from 'satellite.js';
import { findNextIssPass } from '../../data/issPass.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -136,22 +136,98 @@ export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Next ISS pass for an observer. Requires the catalog to have loaded (the
* satellites layer enabled at least once this session).
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}}
*/
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
const sat = layerState._catalog.get(ISS_NORAD);
function getNextIssPass({
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
}) {
return getNextSatellitePass(ISS_NORAD, {
latDeg,
lonDeg,
minElevDeg,
requireVisible,
fromMs,
});
}
/**
* Next pass of any catalog satellite for an observer.
* @param {number} noradId NORAD satellite catalog ID
* @param {Object} options
* @param {number} options.latDeg Observer latitude
* @param {number} options.lonDeg Observer longitude
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
*/
function getNextSatellitePass(
noradId,
{
latDeg,
lonDeg,
minElevDeg = 10,
requireVisible = false,
fromMs = Date.now(),
},
) {
const id = Number(noradId);
if (!Number.isSafeInteger(id) || id <= 0) return { status: 'no-tle' };
const sat = layerState._catalog.get(id);
if (!sat || !sat.satrec) return { status: 'no-tle' };
const pass = findNextIssPass({
const pass = findNextSatellitePass({
satrec: sat.satrec,
latDeg,
lonDeg,
fromMs: Date.now(),
fromMs,
minElevDeg,
requireVisible,
});
return pass ? { status: 'ok', pass } : { status: 'none' };
}
/** Resolve only loaded catalog identities; ambiguous names never pick a first row. */
function resolveSatelliteForPass(query) {
const text = String(query ?? '').trim();
if (!text || text.length > 120) return { status: 'not-found' };
const exactId = /^\d+$/.test(text) ? Number(text) : null;
if (exactId !== null) {
const sat = layerState._catalog.get(exactId);
return sat?.satrec
? { status: 'ok', noradId: exactId, name: sat.name }
: { status: 'not-found' };
}
const q = text.toLowerCase();
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
const exact = rows.filter(
([, sat]) => String(sat.name).toLowerCase() === q,
);
const matches = exact.length
? exact
: rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
if (!matches.length) return { status: 'not-found' };
if (matches.length > 1)
return {
status: 'ambiguous',
candidates: matches
.slice(0, 8)
.map(([noradId, sat]) => ({ noradId, name: sat.name })),
totalMatches: matches.length,
};
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
}
/**
* Score a mission-to-catalog name match. Compact identifier containment handles
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
@@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
orbitalPeriodSeconds,
computeOrbitPath,
getNextIssPass,
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import { satelliteClassLabel } from '../../data/satelliteClass.js';
import { ISS_NORAD } from './policy.js';
/**
* Map one CelesTrak catalog satellite to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. `id` is the display name (same
* convention as vessels); `noradId` is the track_entity key.
* @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}.
* @returns {{id: string, noradId: string|null, name: string|null,
* lat: number|null, lon: number|null, altitudeM: number|null,
* speedMps: number|null, satelliteClass: string|null, group: string|null}}
*/
export function mapAnalystRecord(raw) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
const noradNum =
raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
const noradId = Number.isFinite(noradNum)
? String(Math.trunc(noradNum))
: text(raw?.noradId);
const name = text(raw?.name);
const group = text(raw?.group);
const isIss = noradNum === ISS_NORAD;
return {
id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'),
noradId,
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
altitudeM: num(raw?.altitudeM),
speedMps: num(raw?.speedMps),
// Empty records stay class-less; the class table's fallback is VISUAL and
// must not leak onto a row that has no satellite identity.
satelliteClass:
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
group,
};
}
/** Create on-demand analyst snapshots owned by one layer instance. */
export function createRecords({ state, parts }) {
function analystGeo(noradId, sat, now) {
const pos = sat?.satrec
? parts.orbits.propagatePosition(sat.satrec, now)
: null;
if (
pos &&
Number.isFinite(pos.latitude) &&
Number.isFinite(pos.longitude)
) {
return {
lat: pos.latitude,
lon: pos.longitude,
altitudeM: pos.altitude,
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
};
}
const point = state._points.get(noradId);
if (!point?.position) return null;
const carto = Cesium.Cartographic.fromCartesian(point.position);
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
speedMps: null,
};
}
function getAnalystRecords(maxCount = 2000) {
if (!state._enabled || !state._catalog.size) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const now = new Date();
const result = [];
const emit = (noradId, sat) => {
if (result.length >= limit) return false;
const pos = analystGeo(noradId, sat, now);
if (!pos) return true;
result.push(
mapAnalystRecord({
noradId,
name: sat?.name,
group: sat?.group,
lat: pos.lat,
lon: pos.lon,
altitudeM: pos.altitudeM,
speedMps: pos.speedMps,
}),
);
return result.length < limit;
};
for (const [noradId, sat] of state._catalog) {
if (sat?.group === 'dense') continue;
if (!emit(noradId, sat)) break;
}
if (result.length < limit) {
for (const [noradId, sat] of state._catalog) {
if (sat?.group !== 'dense') continue;
if (!emit(noradId, sat)) break;
}
}
return result;
}
return { getAnalystRecords };
}
+108
View File
@@ -1,5 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import { createSatelliteSource } from './source.js';
import { createSatellitesLayer } from './index.js';
@@ -56,3 +57,110 @@ test('satellite factories keep control state separate and construct without requ
assert.equal(second.getParams().showPoints, true);
assert.notEqual(first.getStats(), second.getStats());
});
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
const source = { readGroup() {} };
const services = Object.fromEntries(
[
'picking',
'focus',
'readout',
'overlays',
'context',
'render',
'layerState',
].map((key) => [key, {}]),
);
services.layerState.isExplicitLayerStateOrigin = () => false;
const layer = createSatellitesLayer({ source, services });
// Cold catalog (no satellites loaded yet)
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
status: 'no-tle',
});
assert.deepEqual(
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// String NORAD ID coercion
assert.deepEqual(
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Invalid / non-finite ID handling
assert.deepEqual(
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
assert.deepEqual(
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
{ status: 'no-tle' },
);
// Populated catalog: verify numeric and string coercion succeed
const L1 =
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
const L2 =
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
const satrec = twoline2satrec(L1, L2);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
});
const FROM_MS = 1221868800000;
const numericResult = layer.getNextSatellitePass(25544, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(numericResult.status, 'ok');
assert.ok(numericResult.pass);
const stringResult = layer.getNextSatellitePass('25544', {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(stringResult.status, 'ok');
assert.deepEqual(stringResult, numericResult);
const issResult = layer.getNextIssPass({
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
});
assert.equal(issResult.status, 'ok');
assert.deepEqual(issResult, numericResult);
layer._setTrackedSatelliteRefreshStateForTest({
noradId: 25544,
name: 'ISS (ZARYA)',
satrec,
neighbours: [
{ noradId: 10, name: 'STARLINK ONE', satrec },
{ noradId: 11, name: 'STARLINK TWO', satrec },
],
});
assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544);
assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous');
assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2);
assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10);
assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found');
layer._clearDenseCatalogStateForTest();
assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found');
// Non-existent satellite ID in populated catalog
assert.deepEqual(
layer.getNextSatellitePass(99999, {
latDeg: 30.2672,
lonDeg: -97.7431,
fromMs: FROM_MS,
}),
{ status: 'no-tle' },
);
});
+13 -2
View File
@@ -54,7 +54,14 @@ export function createSelection({
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
const siblingOwned =
pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
// Cyclone geometry is ambient context behind painted cards. Its handler
// yields to the same foreground AIS hit. Keep moving-contact precedence
// unchanged: those handlers may issue their own camera command.
const cycloneBackground =
siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
if (siblingOwned && !cycloneBackground) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
// usually terrain behind the card. Resolve against the host's current
@@ -64,10 +71,14 @@ export function createSelection({
click.position?.x,
click.position?.y,
{
sourceId: VESSEL_OVERLAY_SOURCE_ID,
...(cycloneBackground
? {}
: { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
},
)
: null;
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
return;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
+218
View File
@@ -0,0 +1,218 @@
export const RADAR_MAX_GAP_MS = 30 * 60_000;
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
/** Transient observed time. Products must honor the signal before installing a frame. */
export function createWeatherClock({
now = Date.now,
setTimeout = globalThis.setTimeout,
clearTimeout = globalThis.clearTimeout,
} = {}) {
const registry = new Map();
const listeners = new Set();
let mode = 'latest';
let target = null;
let playing = false;
let generation = 0;
let timer = null;
let pending = null;
let destroyed = false;
const timesFor = (product) =>
[...new Set(product.getTimes())]
.filter((time) => Number.isFinite(Date.parse(time)))
.sort((a, b) => Date.parse(a) - Date.parse(b));
const getTimeline = () =>
[
...new Set(
[...registry.values()].flatMap(({ product }) =>
product.isSuspended() ? [] : timesFor(product),
),
),
].sort((a, b) => Date.parse(a) - Date.parse(b));
function selectFor(id, time) {
const product = registry.get(id)?.product;
if (!product) return null;
const at = Date.parse(time);
return (
timesFor(product).findLast((candidate) => {
const age = at - Date.parse(candidate);
return age >= 0 && age <= product.maxGapMs;
}) ?? null
);
}
const selectedFor = (product) =>
mode === 'latest'
? (timesFor(product).at(-1) ?? null)
: selectFor(product.id, target);
const getState = () => ({
mode,
target,
playing,
timeline: getTimeline(),
products: [...registry.values()].map(({ product }) => ({
id: product.id,
shown: product.getShownTime() ?? null,
selected: selectedFor(product),
suspended: Boolean(product.isSuspended()),
})),
});
const notify = () => {
if (!destroyed) for (const listener of listeners) listener(getState());
};
const cancelTimer = () => {
if (timer !== null) clearTimeout(timer);
timer = null;
};
const canPlay = () => getTimeline().length >= 2;
function schedule() {
cancelTimer();
if (!playing || destroyed || pending) return;
if (!canPlay()) {
playing = false;
notify();
return;
}
const owner = generation;
timer = setTimeout(() => {
timer = null;
if (destroyed || !playing || owner !== generation) return;
const timeline = getTimeline();
if (timeline.length < 2) {
api.pause();
return;
}
const next = timeline.find(
(time) => Date.parse(time) > Date.parse(target),
);
void api.setTarget(next ?? timeline[0]);
}, 2000);
}
function transition() {
if (destroyed) return Promise.resolve(false);
cancelTimer();
const owner = ++generation;
if (!canPlay()) playing = false;
for (const entry of registry.values()) entry.controller?.abort();
// Start all products together; a rejection must not skip the settle barrier.
const tasks = [...registry.values()].map((entry) => {
entry.controller = new AbortController();
const { signal } = entry.controller;
const time = selectedFor(entry.product);
return Promise.resolve().then(() => {
if (signal.aborted || owner !== generation || destroyed) return false;
return entry.product.apply(time, { signal });
});
});
const result = Promise.allSettled(tasks).then(() => {
if (destroyed || owner !== generation) return false;
pending = null;
if (!canPlay()) playing = false;
notify();
schedule();
return true;
});
pending = result;
notify();
return result;
}
const api = {
register(product) {
if (destroyed) return () => {};
if (registry.has(product.id))
throw new Error(`Duplicate weather product: ${product.id}`);
const entry = { product, controller: null };
registry.set(product.id, entry);
void api.refresh();
return () => {
if (registry.get(product.id) !== entry) return;
entry.controller?.abort();
registry.delete(product.id);
void api.refresh();
};
},
getTimeline,
selectFor,
setTarget(time) {
if (destroyed) return Promise.resolve(false);
if (!Number.isFinite(Date.parse(time)))
throw new TypeError('Weather target must be a UTC time');
const normalized = new Date(time).toISOString();
if (mode === 'history' && target === normalized)
return pending ?? Promise.resolve(true);
mode = 'history';
target = normalized;
return transition();
},
step(direction) {
if (destroyed || ![-1, 1].includes(direction))
return Promise.resolve(false);
api.pause();
const timeline = getTimeline();
if (!timeline.length) return Promise.resolve(false);
const at = Date.parse(target ?? timeline.at(-1));
const next =
direction < 0
? (timeline.findLast((time) => Date.parse(time) < at) ?? timeline[0])
: (timeline.find((time) => Date.parse(time) > at) ?? timeline.at(-1));
return api.setTarget(next);
},
latest() {
if (destroyed) return Promise.resolve(false);
mode = 'latest';
target = null;
playing = false;
return transition();
},
play() {
if (destroyed || playing || !canPlay())
return pending ?? Promise.resolve(false);
playing = true;
if (mode === 'latest') {
const timeline = getTimeline();
return api.setTarget(
timeline.findLast((time) => Date.parse(time) <= now()) ?? timeline[0],
);
}
notify();
schedule();
return pending ?? Promise.resolve(true);
},
pause() {
cancelTimer();
if (!playing) return;
playing = false;
notify();
},
togglePlay() {
return playing ? api.pause() : api.play();
},
refresh() {
if (destroyed) return Promise.resolve(false);
if (!canPlay()) {
playing = false;
cancelTimer();
}
if (mode === 'history') return transition();
notify();
return Promise.resolve(true);
},
getState,
subscribe(listener) {
if (!destroyed) listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
if (destroyed) return;
destroyed = true;
++generation;
playing = false;
cancelTimer();
for (const entry of registry.values()) entry.controller?.abort();
registry.clear();
listeners.clear();
pending = null;
},
};
return api;
}
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherClock,
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const time = (minutes) =>
new Date(Date.UTC(2026, 8, 21, 12, minutes)).toISOString();
const flush = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
function product(id, minutes, maxGapMs = RADAR_MAX_GAP_MS) {
const p = {
id,
maxGapMs,
times: minutes.map(time),
shown: null,
suspended: false,
calls: [],
getTimes: () => p.times,
getShownTime: () => p.shown,
isSuspended: () => p.suspended,
async apply(selected, { signal }) {
p.calls.push({ selected, signal });
if (signal.aborted) return false;
p.shown = selected;
return true;
},
};
return p;
}
function harness(t) {
let at = Date.parse(time(20));
const timers = new Map();
let id = 0;
const clock = createWeatherClock({
now: () => at,
setTimeout(fn, delay) {
timers.set(++id, { fn, due: at + delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
});
t.after(() => clock.destroy());
return {
clock,
timers,
tick(ms) {
at += ms;
for (const [id, timer] of [...timers]) {
if (timer.due > at) continue;
timers.delete(id);
timer.fn();
}
},
};
}
test('union excludes suspended products; nearest selection never uses future or out-of-gap frames', (t) => {
const { clock } = harness(t);
const radar = product('radar', [10, 0, 10]);
const regional = product('regional', [5, 10]);
const global = product('global', [-180, -60], GLOBAL_INFRARED_MAX_GAP_MS);
const hidden = product('hidden', [99]);
hidden.suspended = true;
for (const p of [radar, regional, global, hidden]) clock.register(p);
assert.deepEqual(clock.getTimeline(), [-180, -60, 0, 5, 10].map(time));
assert.equal(clock.selectFor('radar', time(9)), time(0));
assert.equal(clock.selectFor('radar', time(-1)), null);
assert.equal(clock.selectFor('radar', time(40)), time(10));
assert.equal(clock.selectFor('radar', time(41)), null);
assert.equal(clock.selectFor('global', time(120)), time(-60));
assert.equal(clock.selectFor('global', time(121)), null);
assert.equal(clock.selectFor('missing', time(10)), null);
assert.equal(REGIONAL_INFRARED_MAX_GAP_MS, 30 * 60_000);
assert.equal(LIGHTNING_MAX_GAP_MS, 30 * 60_000);
});
test('history applies per-product selections, including null; latest restores unequal newest times', async (t) => {
const { clock } = harness(t);
const radar = product('radar', [0, 10, 20]);
const satellite = product('satellite', [5, 15]);
const lightning = product('lightning', [15]);
for (const p of [radar, satellite, lightning]) clock.register(p);
await clock.setTarget(time(10));
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(10), time(5), null],
);
assert.deepEqual(
clock.getState().products.map(({ selected }) => selected),
[time(10), time(5), null],
);
await clock.latest();
assert.deepEqual(
[radar.shown, satellite.shown, lightning.shown],
[time(20), time(15), time(15)],
);
assert.equal(clock.getState().mode, 'latest');
assert.equal(clock.getState().target, null);
assert.equal(clock.getState().playing, false);
});
test('superseded completions cannot commit, notify or schedule a new playback advance', async (t) => {
const { clock, timers } = harness(t);
const p = product('radar', [0, 10, 20]);
const loads = [];
p.apply = (selected, { signal }) =>
new Promise((resolve) => {
loads.push({
selected,
signal,
finish() {
if (!signal.aborted) p.shown = selected;
resolve(!signal.aborted);
},
});
});
clock.register(p);
let notifications = 0;
clock.subscribe(() => notifications++);
const old = clock.setTarget(time(0));
await flush();
const current = clock.setTarget(time(10));
await flush();
assert.equal(loads[0].signal.aborted, true);
loads[1].finish();
assert.equal(await current, true);
const count = notifications;
loads[0].finish();
assert.equal(await old, false);
assert.equal(notifications, count);
assert.equal(p.shown, time(10));
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
});
test('steps use strict union neighbours from arbitrary targets and clamp at endpoints', async (t) => {
const { clock } = harness(t);
clock.register(product('radar', [0, 10, 20]));
clock.register(product('satellite', [5, 15]));
await clock.step(-1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(12));
await clock.step(-1);
assert.equal(clock.getState().target, time(10));
await clock.setTarget(time(12));
await clock.step(1);
assert.equal(clock.getState().target, time(15));
await clock.setTarget(time(0));
await clock.step(-1);
assert.equal(clock.getState().target, time(0));
await clock.setTarget(time(20));
await clock.step(1);
assert.equal(clock.getState().target, time(20));
});
test('playback waits for every product to settle, including rejection, then waits 2000 ms and wraps', async (t) => {
const { clock, tick, timers } = harness(t);
const a = product('radar', [0, 10]);
const b = product('satellite', [5]);
const loads = [];
b.apply = () =>
new Promise((resolve, reject) => loads.push({ resolve, reject }));
clock.register(a);
clock.register(b);
const start = clock.play();
await flush();
assert.equal(clock.getState().target, time(10));
tick(10_000);
assert.equal(timers.size, 0);
assert.equal(a.calls.length, 1);
loads[0].reject(new Error('unavailable'));
await start;
assert.equal(timers.size, 1);
tick(1999);
assert.equal(clock.getState().target, time(10));
tick(1);
await flush();
assert.equal(clock.getState().target, time(0));
assert.equal(timers.size, 0);
tick(10_000);
assert.equal(clock.getState().target, time(0));
loads[1].resolve(true);
await flush();
tick(2000);
await flush();
assert.equal(clock.getState().target, time(5));
clock.pause();
loads[2].resolve(false);
await flush();
assert.equal(timers.size, 0, 'pausing during a load prevents rescheduling');
});
test('pause cancels a pending advance; playback stops with one time or all products suspended', async (t) => {
const { clock, tick, timers } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.play();
await clock.play();
assert.equal(timers.size, 1);
clock.pause();
tick(2000);
assert.equal(clock.getState().target, time(10));
assert.equal(timers.size, 0);
await clock.togglePlay();
p.times = [time(0)];
await clock.refresh();
assert.equal(clock.getState().playing, false);
await clock.play();
assert.equal(clock.getState().playing, false);
p.times = [time(0), time(10)];
await clock.refresh();
await clock.play();
p.suspended = true;
await clock.refresh();
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(timers.size, 0);
});
test('refresh does not apply in latest; history retains its target as expired frames disappear', async (t) => {
const { clock } = harness(t);
const p = product('radar', [0, 10]);
clock.register(p);
await clock.refresh();
assert.equal(p.calls.length, 0);
await clock.setTarget(time(10));
p.times = [time(0), time(20)];
await clock.refresh();
assert.equal(p.shown, time(0));
p.times = [time(20)];
await clock.refresh();
assert.equal(p.shown, null);
assert.equal(clock.getState().target, time(10));
});
test('registration joins history; unregister and destroy abort work and release subscriptions/timers', async (t) => {
const { clock, timers, tick } = harness(t);
const p = product('radar', [0, 10]);
const unregister = clock.register(p);
await clock.setTarget(time(0));
const b = product('satellite', [0, 5]);
clock.register(b);
await flush();
assert.equal(b.shown, time(0));
await clock.play();
unregister();
unregister();
await flush();
assert.equal(p.calls.at(-1).signal.aborted, true);
assert.deepEqual(
clock.getState().products.map(({ id }) => id),
['satellite'],
);
let notifications = 0;
const off = clock.subscribe(() => notifications++);
clock.pause();
off();
const before = notifications;
await clock.play();
assert.equal(notifications, before);
clock.destroy();
clock.destroy();
tick(2000);
assert.equal(timers.size, 0);
assert.equal(b.calls.at(-1).signal.aborted, true);
assert.equal(clock.getState().playing, false);
assert.deepEqual(clock.getTimeline(), []);
assert.equal(await clock.setTarget(time(5)), false);
});
+15
View File
@@ -0,0 +1,15 @@
/** Resolve the visible surface that can own weather imagery without scene mutation. */
export function resolveImageryHost({ viewer, tileset }) {
if (viewer?.scene?.globe?.show === true)
return { collection: viewer.imageryLayers, kind: 'globe' };
if (tileset?.imageryLayers)
return { collection: tileset.imageryLayers, kind: 'tileset' };
return { collection: null, kind: 'none' };
}
export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map';
/** Report imagery suspension without changing or detaching the resolved host. */
export function imageryHostStatus(host) {
return host.kind === 'none' ? NO_IMAGERY_HOST : null;
}
+39
View File
@@ -0,0 +1,39 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
resolveImageryHost,
imageryHostStatus,
NO_IMAGERY_HOST,
} from './imageryHost.js';
test('only a missing host suspends weather; 3D Tiles has no height gate', () => {
assert.equal(imageryHostStatus({ kind: 'tileset' }), null);
assert.equal(imageryHostStatus({ kind: 'globe' }), null);
assert.equal(imageryHostStatus({ kind: 'none' }), NO_IMAGERY_HOST);
});
test('visible globe takes precedence over a tileset', () => {
const viewer = { imageryLayers: {}, scene: { globe: { show: true } } };
assert.deepEqual(
resolveImageryHost({ viewer, tileset: { imageryLayers: {} } }),
{ collection: viewer.imageryLayers, kind: 'globe' },
);
});
test('hidden globe uses the supplied tileset imagery collection', () => {
const tileset = { imageryLayers: {} };
const viewer = { scene: { globe: { show: false } } };
assert.deepEqual(resolveImageryHost({ viewer, tileset }), {
collection: tileset.imageryLayers,
kind: 'tileset',
});
});
test('hidden globe without an imagery-capable tileset has no host', () => {
for (const tileset of [null, {}])
assert.deepEqual(
resolveImageryHost({
viewer: { scene: { globe: { show: false } } },
tileset,
}),
{ collection: null, kind: 'none' },
);
});
+17
View File
@@ -0,0 +1,17 @@
const priorities = new WeakMap();
/** Scalar context, infrared, radar, then lightning: independent of enable order. */
export function orderWeatherImagery(collection, layer, priority) {
priorities.set(layer, priority);
if (typeof collection.raiseToTop !== 'function') return;
const weather = [];
for (let i = 0; i < collection.length; i++) {
const item = collection.get(i);
if (priorities.has(item)) weather.push(item);
}
const ordered = [...weather].sort(
(a, b) => priorities.get(a) - priorities.get(b),
);
if (weather.every((item, i) => item === ordered[i])) return;
for (const item of ordered) collection.raiseToTop(item);
}
+72
View File
@@ -0,0 +1,72 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { orderWeatherImagery } from './imageryOrder.js';
test('weather compositing remains consistent when scalar, observation and history arrive out of order', () => {
const base = { name: 'basemap' },
radar = { name: 'radar' },
cloud = { name: 'cloud' },
wind = { name: 'wind' },
lightning = { name: 'lightning' };
const items = [base];
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const [layer, priority] of [
[radar, 2],
[cloud, 1],
[lightning, 3],
[wind, 0],
]) {
items.push(layer);
orderWeatherImagery(collection, layer, priority);
}
assert.deepEqual(items, [base, wind, cloud, radar, lightning]);
const next = { name: 'new radar history frame' };
items.push(next);
orderWeatherImagery(collection, next, 2);
assert.deepEqual(items, [base, wind, cloud, radar, next, lightning]);
});
test('tileset imagery collection preserves scalar, infrared, radar and lightning order', async () => {
const { ImageryLayerCollection, ImageryLayer } = await import('cesium');
const tileset = { imageryLayers: new ImageryLayerCollection() };
const layers = Array.from({ length: 4 }, () => new ImageryLayer());
for (const priority of [3, 2, 0, 1]) {
tileset.imageryLayers.add(layers[priority]);
orderWeatherImagery(tileset.imageryLayers, layers[priority], priority);
}
assert.deepEqual(
layers.map((_, i) => tileset.imageryLayers.get(i)),
layers,
);
tileset.imageryLayers.destroy();
});
test('already ordered imagery adds and repeated ordering cause no collection churn', () => {
const items = [];
let raises = 0;
const collection = {
get length() {
return items.length;
},
get: (i) => items[i],
raiseToTop(layer) {
raises++;
items.push(...items.splice(items.indexOf(layer), 1));
},
};
for (const priority of [0, 1, 1, 2, 3]) {
const layer = {};
items.push(layer);
orderWeatherImagery(collection, layer, priority);
orderWeatherImagery(collection, layer, priority);
}
assert.equal(raises, 0);
});
+727
View File
@@ -0,0 +1,727 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
GLOBAL_INFRARED_MAX_GAP_MS,
LIGHTNING_MAX_GAP_MS,
} from './clock.js';
const STOPS = [
[10, '#00ecec'],
[20, '#0000f6'],
[30, '#00c800'],
[40, '#ffff00'],
[50, '#ff9000'],
[60, '#dc0000'],
[70, '#ff00ff'],
[80, '#05ede0'],
];
const LIGHTNING_STOPS = [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
];
const utc = (value) =>
value ? `${value.slice(5, 16).replace('T', ' ')} UTC` : 'Unavailable';
/** A per-application observation layer, using the existing layer lifecycle and
* row controls. History is transient: shared links always open latest imagery. */
export function createWeatherLayer({
feed,
clock,
id = 'weather-radar',
cesium = Cesium,
createRendering = createWeatherRendering,
documentRef = globalThis.document,
eventTarget = globalThis.window,
matchMedia = globalThis.matchMedia?.bind(globalThis),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Weather requires a snapshot source');
const radar = id === 'weather-radar';
const lightning = id === 'weather-lightning';
const satellite = !radar && !lightning;
let product = radar ? 'radar' : lightning ? 'lightning' : 'clouds-regional';
let opacity = 'strong';
let infrared = 'filtered';
let viewer = null,
rendering = null,
manifest = null,
request = null,
listener = null;
let enabled = false,
loading = false,
playing = false,
followLatest = true;
let generation = 0,
timer = null,
error = null,
removeCamera = null;
let motion = null;
let runNavigation = null;
let imageryHost = null;
let hostCollection;
let hostHidden = false;
let hostStatus = null;
let unregisterClock = null;
let frameRequest = null;
let noFrame = false;
const maxGap = () =>
radar
? RADAR_MAX_GAP_MS
: lightning
? LIGHTNING_MAX_GAP_MS
: product === 'clouds'
? GLOBAL_INFRARED_MAX_GAP_MS
: REGIONAL_INFRARED_MAX_GAP_MS;
const isLatest = () =>
clock ? clock.getState().mode === 'latest' : followLatest;
const getHost = () =>
imageryHost?.() ?? { collection: viewer?.imageryLayers, kind: 'globe' };
const notify = () => listener?.();
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
const unsubscribeClock = clock?.subscribe(() => {
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
notify();
});
const observationDelayed = () =>
manifest?.latest &&
Date.now() - Date.parse(manifest.latest) >
(product === 'clouds' ? 240 : lightning ? 45 : 20) * 60_000;
const stop = () => {
if (clock) return;
playing = false;
rendering?.cancelPrefetch?.();
clearTimeout(timer);
timer = null;
};
const suspended = () => hostHidden || documentRef?.hidden || motion?.matches;
const onVisibility = () => {
if (suspended()) rendering?.cancelPrefetch?.();
if (clock) void clock.refresh();
else if (suspended()) stop();
notify();
};
function checkHost(resume = true) {
const host = getHost();
const status = imageryHostStatus(host);
const changed = host.collection !== hostCollection || hostStatus !== status;
hostCollection = host.collection;
hostStatus = status;
hostHidden = status !== null;
// Keep playback intent and the displayed time while the host is unavailable.
if (!changed || !rendering) return;
++generation;
rendering.rehome?.();
clearTimeout(timer);
timer = null;
loading = Boolean(request && !request.signal.aborted);
if (clock && enabled) {
if (resume) {
void clock.refresh();
if (!hostHidden && manifest && isLatest()) void show(manifest.latest);
}
notify();
return;
}
if (resume && enabled && !hostHidden && manifest) {
const time =
followLatest || !manifest.times.includes(shownTime())
? manifest.latest
: shownTime();
if (
time !== shownTime() ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
void show(time);
else schedule();
}
notify();
}
const onMapStackChanged = () => checkHost();
function schedule() {
if (clock) return;
clearTimeout(timer);
timer = null;
if (!enabled || !playing || suspended()) return;
timer = setTimeout(() => {
timer = null;
const times = manifest?.times || [];
const next = (times.indexOf(shownTime()) + 1) % times.length;
if (times[next]) void show(times[next]);
else stop();
}, 2000);
}
async function applyClockTime(time, { signal }) {
if (!enabled || signal.aborted) return false;
if (time !== null) return show(time, signal);
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = true;
rendering?.setHidden(true);
loading = Boolean(request && !request.signal.aborted);
notify();
return true;
}
function registerClock() {
if (!clock || unregisterClock || !rendering || !enabled) return;
unregisterClock = clock.register({
id,
get maxGapMs() {
return maxGap();
},
getTimes: () => manifest?.times ?? [],
getShownTime: shownTime,
apply: applyClockTime,
isSuspended: suspended,
});
}
async function warmNext(time) {
if (
!enabled ||
suspended() ||
!(clock ? clock.getState().playing : playing)
)
return;
const times = manifest?.times ?? [];
const next = times[(times.indexOf(time) + 1) % times.length];
if (!next || next === time) return;
try {
await rendering.prefetch?.(manifest, next, { infrared });
} catch {
// Speculative work must not change the displayed frame or playback state.
}
}
async function show(time, signal) {
checkHost(false);
if (!enabled || hostHidden || !manifest?.times?.includes(time))
return false;
signal?.throwIfAborted();
frameRequest?.abort();
const controller = new AbortController();
frameRequest = controller;
const abort = () => controller.abort();
signal?.addEventListener('abort', abort, { once: true });
const owner = ++generation;
loading = true;
notify();
try {
const ok = await rendering.setFrame(manifest, time, {
signal: controller.signal,
infrared,
});
if (owner !== generation || !enabled || controller.signal.aborted)
return false;
if (ok) {
noFrame = false;
rendering.setHidden?.(false);
void warmNext(time);
}
error = ok ? null : 'Frame unavailable; previous observation retained';
if (!ok) stop();
return ok;
} catch {
if (owner === generation && !controller.signal.aborted) {
error = 'Weather imagery unavailable';
stop();
}
return false;
} finally {
signal?.removeEventListener('abort', abort);
if (frameRequest === controller) frameRequest = null;
if (owner === generation) {
loading = Boolean(request && !request.signal.aborted);
notify();
if (clock && isLatest()) void clock.refresh();
schedule();
}
}
}
const layer = {
id,
name: radar
? 'Rain radar'
: lightning
? 'Lightning density'
: 'Satellite clouds',
icon: radar ? '◉' : lightning ? 'ϟ' : '☁',
source: 'NOAA nowCOAST · OBSERVED',
updateInterval: lightning ? 600_000 : 120_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({
viewer,
cesium,
getHost,
onChange: notify,
});
checkHost(false);
eventTarget?.addEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
motion = matchMedia?.('(prefers-reduced-motion: reduce)');
motion?.addEventListener?.('change', onVisibility);
documentRef?.addEventListener?.('visibilitychange', onVisibility);
registerClock();
removeCamera = viewer.camera.moveEnd?.addEventListener(() => {
checkHost();
if (enabled) notify();
});
},
attachShellServices(services) {
imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
checkHost();
runNavigation =
typeof services?.runNavigation === 'function'
? services.runNavigation
: null;
notify();
},
enable() {
enabled = true;
registerClock();
},
disable() {
enabled = false;
unregisterClock?.();
unregisterClock = null;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
rendering?.clear();
manifest = null;
loading = false;
error = null;
followLatest = true;
},
async update(_viewer, { signal } = {}) {
if (!enabled) return false;
checkHost(false);
clearTimeout(timer);
timer = null;
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
request = controller;
const abort = () => {
controller.abort(signal.reason);
stop();
};
signal?.addEventListener('abort', abort, { once: true });
loading = true;
notify();
try {
signal?.throwIfAborted();
const snapshot = await feed.getSnapshot({
product,
signal: controller.signal,
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
if (snapshot.unavailable) {
error = 'Weather source unavailable; previous observation retained';
stop();
return true;
}
manifest = snapshot;
error = null;
if (clock) {
await clock.refresh();
if (
isLatest() &&
request === controller &&
!controller.signal.aborted
)
await show(snapshot.latest, controller.signal);
return (
!controller.signal.aborted && request === controller && enabled
);
}
const time =
followLatest || !snapshot.times.includes(shownTime())
? snapshot.latest
: shownTime();
if (
shownTime() !== time ||
(satellite && rendering.getDiagnostics().infrared !== infrared)
)
await show(time, controller.signal);
return !controller.signal.aborted && request === controller && enabled;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Weather unavailable';
stop();
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = Boolean(rendering?.getDiagnostics().loading);
notify();
schedule();
}
}
},
setParams(params = {}) {
const infraredChanged =
satellite &&
['filtered', 'full'].includes(params.infrared) &&
params.infrared !== infrared;
if (infraredChanged) infrared = params.infrared;
if (['light', 'strong'].includes(params.opacity)) {
opacity = params.opacity;
rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8);
}
if (
satellite &&
['clouds', 'clouds-regional'].includes(params.product) &&
params.product !== product
) {
product = params.product;
++generation;
frameRequest?.abort();
frameRequest = null;
noFrame = false;
request?.abort();
request = null;
stop();
manifest = null;
error = null;
loading = false;
followLatest = true;
rendering?.clear();
if (clock) void clock.refresh();
if (enabled) void layer.update(viewer);
}
if (infraredChanged && enabled && manifest) {
clearTimeout(timer);
timer = null;
if (clock && !isLatest()) void clock.refresh();
else void show(shownTime() || manifest.latest);
}
if (
params.focus === true &&
enabled &&
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
b.west,
b.south,
b.east,
b.north,
),
duration: motion?.matches ? 0 : 1.4,
}),
);
}
if (clock && enabled) {
if (params.play === true) void clock.togglePlay();
if (params.latest === true) void clock.latest();
if ([-1, 1].includes(params.step)) void clock.step(params.step);
}
if (
!clock &&
params.play === true &&
enabled &&
manifest?.times?.length > 1 &&
!suspended()
) {
playing = !playing;
followLatest = false;
if (playing) schedule();
else stop();
}
if (!clock && params.latest === true && manifest) {
stop();
followLatest = true;
void show(manifest.latest);
}
if (!clock && [-1, 1].includes(params.step) && manifest && !loading) {
stop();
followLatest = false;
const at = manifest.times.indexOf(shownTime());
const next = Math.max(
0,
Math.min(manifest.times.length - 1, at + params.step),
);
void show(manifest.times[next]);
}
notify();
},
getParams() {
return radar
? { opacity }
: satellite
? { product, opacity, infrared }
: { product, opacity };
},
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
const missing =
noFrame &&
shared?.mode === 'history' &&
shared.products.find((entry) => entry.id === id)?.selected === null
? `No frame within ${maxGap() / 60_000 < 60 ? `${maxGap() / 60_000} min` : `${maxGap() / 3600_000} h`} of ${utc(shared.target)}`
: null;
const time = shownTime();
const relation =
shared?.mode === 'history' && time
? Date.parse(time) === Date.parse(shared.target)
? ' · synced'
: Date.parse(time) < Date.parse(shared.target)
? ' · nearest'
: ''
: '';
const times = manifest?.times || [];
const index = times.indexOf(time);
const current = index >= 0;
const age = time
? Math.max(0, Math.floor((Date.now() - Date.parse(time)) / 60000))
: null;
const lag =
age === null
? ''
: age >= 60
? `${Math.floor(age / 60)}h ${age % 60}m ago`
: `${age}m ago`;
const diagnostic = rendering?.getDiagnostics();
const b = manifest?.bounds;
const canvas = viewer?.scene?.canvas;
const point =
canvas &&
viewer.camera.pickEllipsoid?.(
new cesium.Cartesian2(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
),
viewer.scene.globe.ellipsoid,
);
const camera = point
? cesium.Cartographic.fromCartesian(point, viewer.scene.globe.ellipsoid)
: null;
const lon = camera ? cesium.Math.toDegrees(camera.longitude) : 0;
const lat = camera ? cesium.Math.toDegrees(camera.latitude) : 0;
const outside =
b &&
camera &&
(lon < b.west ||
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
const controls = {
readout: true,
summary: {
label: radar
? 'Rain radar · US'
: lightning
? 'Lightning density · 15 min'
: 'Satellite clouds',
coverage: radar
? 'CONUS'
: lightning
? 'Americas + Pacific'
: product === 'clouds'
? 'Global · 60°S–60°N'
: 'North America',
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail: time
? `${followLatest ? 'Observed' : 'History'} · ${utc(time)} · ${lag}${relation}`
: missing
? 'Observation unavailable'
: 'Waiting for observation',
status:
missing ||
hostStatus ||
error ||
diagnostic?.error ||
(observationDelayed()
? 'Source observations delayed'
: manifest?.stale
? 'Stale source'
: loading
? 'Loading next frame…'
: outside
? 'Map center outside coverage'
: null),
units: radar ? 'dBZ' : lightning ? 'strikes/km²/min ×10³' : '',
},
chips: [
...(satellite
? [
['clouds-regional', 'N. America'],
['clouds', 'Global'],
].map(([value, label]) => ({
id: value,
label,
active: product === value,
params: { product: value },
title:
value === 'clouds'
? 'Hourly global mosaic; usually 2–3 hours delayed'
: 'GOES regional clouds; approximately 5-minute updates',
}))
: []),
...(satellite
? [
{
id: 'filtered',
label: 'Clouds only',
active: infrared === 'filtered',
params: { infrared: 'filtered' },
title:
'Dim everything but the bright, cold cloud tops; a brightness filter, not a cloud mask',
},
{
id: 'full',
label: 'Full',
active: infrared === 'full',
params: { infrared: 'full' },
title: 'The complete infrared image at the chosen opacity',
},
]
: []),
...['light', 'strong'].map((value) => ({
id: `opacity-${value}`,
label: value === 'light' ? 'Soft' : 'Vivid',
active: opacity === value,
params: { opacity: value },
title: 'Image opacity; does not alter the observed values',
})),
{
id: 'coverage',
label: radar
? 'View US radar'
: lightning
? 'View Americas & Pacific'
: 'View coverage',
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? `${label} strikes/km²/min ×10³ (15-minute density)`
: `${label} dBZ radar reflectivity`,
}))
: [],
info: hostHidden
? hostStatus
: `${radar ? 'RADAR REFLECTIVITY · dBZ' : lightning ? 'LIGHTNING DENSITY · 15 min accumulation' : product === 'clouds' ? 'GLOBAL INFRARED · hourly' : 'GOES INFRARED · ~5 min'}\n${time ? `${followLatest ? 'Latest observation' : 'History'}: ${utc(time)}\n${lag}${current && !followLatest ? ` · frame ${index + 1}/${times.length}` : ''}${loading ? ' · loading' : ''}` : `Observation: unavailable${loading ? ' · loading' : ''}`}${missing ? `\n${missing}` : ''}${manifest?.stale ? '\nSTALE · cached source metadata' : ''}${error || diagnostic?.error ? '\n' + (error || diagnostic.error) : ''}\n${radar ? 'Contiguous US · gaps ≠ no rain' : lightning ? 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³' : product === 'clouds' ? '60°S–60°N · typically 2–3 h delayed' : 'North America · infrared imagery'}${outside ? '\nMap center is outside source coverage' : ''}${motion?.matches ? (clock ? '\nReduced motion · history playback unavailable' : '\nReduced motion · manual history available') : ''}`,
infoTitle: lightning
? 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.'
: radar
? 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.'
: 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
};
controls.summary.settings = [
...(satellite
? [
{
id: 'region',
label: 'REGION',
chips: controls.chips.filter(({ params }) => params.product),
},
{
id: 'image',
label: 'IMAGE',
chips: controls.chips.filter(({ params }) => params.infrared),
},
]
: []),
{
id: 'opacity',
label: 'OPACITY',
chips: controls.chips.filter(({ params }) => params.opacity),
},
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'coverage',
);
return controls;
},
setRowControlsListener(value) {
listener = typeof value === 'function' ? value : null;
},
getStats() {
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? 'Observed' : 'History',
lastUpdate: shownTime() ? Date.parse(shownTime()) : null,
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
observedAt: shownTime(),
};
},
getDiagnostics() {
const shared = clock?.getState();
return {
...rendering?.getDiagnostics(),
playing: shared ? shared.playing : playing && !hostHidden,
followLatest: isLatest(),
...(shared
? {
clock: {
mode: shared.mode,
target: shared.target,
playing: shared.playing,
},
}
: {}),
historyFrames: manifest?.times?.length || 0,
timerActive: timer !== null,
};
},
destroy() {
layer.disable();
unsubscribeClock?.();
removeCamera?.();
removeCamera = null;
motion?.removeEventListener?.('change', onVisibility);
documentRef?.removeEventListener?.('visibilitychange', onVisibility);
eventTarget?.removeEventListener?.(
'gev:map-stack-changed',
onMapStackChanged,
);
runNavigation = null;
imageryHost = null;
viewer = null;
rendering = null;
listener = null;
},
};
return layer;
}
+23
View File
@@ -0,0 +1,23 @@
export const INFRARED_ALPHA_T0 = 0.4;
export const INFRARED_ALPHA_T1 = 0.7;
/** Return display pixels without changing RGB or the source RGBA bytes. */
export function infraredAlpha(rgba, mode = 'filtered') {
const pixels = new Uint8ClampedArray(rgba);
if (mode === 'full') return pixels;
for (let i = 0; i < pixels.length; i += 4) {
// Match Cesium's czm_srgbToLinear conversion in draped imagery.
const linearMax =
(Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2;
const t = Math.max(
0,
Math.min(
1,
(linearMax - INFRARED_ALPHA_T0) /
(INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0),
),
);
pixels[i + 3] = rgba[i + 3] * t * t * (3 - 2 * t);
}
return pixels;
}
+48
View File
@@ -0,0 +1,48 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
infraredAlpha,
INFRARED_ALPHA_T0,
INFRARED_ALPHA_T1,
} from './infraredAlpha.js';
test('filtered infrared preserves RGB and input bytes, scales existing alpha, and matches the old linear threshold', () => {
assert.equal(INFRARED_ALPHA_T0, 0.4);
assert.equal(INFRARED_ALPHA_T1, 0.7);
const bytes = new Uint8ClampedArray([
0, 0, 0, 255, 168, 12, 50, 255, 12, 194, 50, 200, 12, 50, 217, 128, 255,
255, 255, 0,
]);
const original = bytes.slice();
const result = infraredAlpha(bytes);
assert.deepEqual(bytes, original);
for (let i = 0; i < bytes.length; i += 4)
assert.deepEqual(result.slice(i, i + 3), bytes.slice(i, i + 3));
assert.deepEqual(
[result[3], result[7], result[11], result[15], result[19]],
[0, 0, 98, 128, 0],
);
// Cesium's old 0.55 hard cut falls between byte values 194 and 195.
assert.ok((194 / 255) ** 2.2 < 0.55);
assert.ok((195 / 255) ** 2.2 > 0.55);
const ramp = [];
for (let value = 0; value < 256; value++) {
ramp.push(infraredAlpha([value, value, value, 255])[3]);
}
assert.ok(ramp[194] < 127.5 && ramp[195] > 127.5);
assert.ok(ramp.every((value, i) => !i || value >= ramp[i - 1]));
assert.ok(Math.max(...ramp.slice(1).map((v, i) => v - ramp[i])) <= 8);
assert.ok(
Math.abs(
(0.55 - INFRARED_ALPHA_T0) / (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0) -
0.5,
) < 1e-12,
);
});
test('full infrared preserves every channel including partial and zero alpha', () => {
const pixels = new Uint8ClampedArray([
12, 50, 194, 80, 0, 0, 0, 255, 255, 200, 0, 0,
]);
assert.deepEqual(infraredAlpha(pixels, 'full'), pixels);
});
+117
View File
@@ -0,0 +1,117 @@
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; `flipY` draws a
* bottom-up image back upright. */
export function processInfraredImage(
image,
mode,
createCanvas,
{ flipY = false } = {},
) {
const canvas = createCanvas();
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
context.drawImage(image, 0, 0);
if (flipY) context.setTransform(1, 0, 0, 1, 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,
});
}
+176
View File
@@ -0,0 +1,176 @@
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 = () => ({
setTransform: (...matrix) => {
output.transforms = [...(output.transforms ?? []), matrix];
},
drawImage: (image) => {
output.image = image;
output.drawnWith = output.transforms?.at(-1) ?? null;
},
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('flipY draws a bottom-up image upright, then restores the transform', () => {
const image = { width: 4, height: 8 };
assert.equal(processInfraredImage(image, 'full', canvas).drawnWith, null);
const output = processInfraredImage(image, 'full', canvas, { flipY: true });
assert.deepEqual(output.drawnWith, [1, 0, 0, -1, 0, 8]);
assert.deepEqual(output.transforms.at(-1), [1, 0, 0, 1, 0, 0]);
});
test('mosaic fetch is capped at 4 MiB, forwards cancellation, and releases decoded images', async () => {
const controller = new AbortController();
let closes = 0,
fetches = 0,
clock = 10,
fetched = false;
const options = {
signal: controller.signal,
mode: 'filtered',
createCanvas: canvas,
now: () => clock,
onFetched: () => {
fetched = true;
},
fetchImpl: async (url, { signal }) => {
fetches++;
assert.match(url, /^\/api\/weather\/image\?product=clouds&time=/);
assert.equal(signal, controller.signal);
return new Response(new Uint8Array([1, 2, 3]));
},
decodeImage: async (blob) => {
assert.equal(fetched, true);
assert.equal(blob.size, 3);
assert.equal(blob.type, 'image/png');
clock = 25;
return {
width: 2048,
height: 1024,
close() {
closes++;
},
};
},
};
const result = await acquireInfraredMosaic(
'2026-09-15T20:00:00.000Z',
options,
);
assert.equal(fetches, 1);
assert.equal(closes, 1);
assert.equal(result.decodeMs, 15);
assert.equal(result.texture.width, 2048);
for (const response of [
new Response('', {
headers: { 'content-length': String(MAX_MOSAIC_BYTES + 1) },
}),
new Response(new Uint8Array(MAX_MOSAIC_BYTES + 1)),
]) {
await assert.rejects(
acquireInfraredMosaic('2026-09-15', {
...options,
fetchImpl: async () => response,
decodeImage: () => assert.fail('oversized image must not decode'),
}),
{ code: 'RESPONSE_TOO_LARGE' },
);
}
await assert.rejects(
acquireInfraredMosaic('2026-09-15', {
...options,
decodeImage: async () => {
controller.abort();
return {
width: 2048,
height: 1024,
close() {
closes++;
},
};
},
}),
{ name: 'AbortError' },
);
assert.equal(closes, 2);
});
test('bitmap decoding and Image fallback release resources on success, error and abort', async (t) => {
const original = Object.getOwnPropertyDescriptor(
globalThis,
'createImageBitmap',
);
t.after(() => {
if (original)
Object.defineProperty(globalThis, 'createImageBitmap', original);
else delete globalThis.createImageBitmap;
});
const bitmap = {};
globalThis.createImageBitmap = async () => bitmap;
assert.equal(
await decodeInfraredImage(new Blob(), new AbortController().signal),
bitmap,
);
globalThis.createImageBitmap = undefined;
const images = [],
revoked = [];
const originalImage = globalThis.Image;
globalThis.Image = class {
constructor() {
images.push(this);
}
};
t.after(() => {
if (originalImage) globalThis.Image = originalImage;
else delete globalThis.Image;
});
t.mock.method(URL, 'createObjectURL', () => 'blob:test');
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
for (const action of ['load', 'error', 'abort']) {
const controller = new AbortController();
const pending = decodeInfraredImage(new Blob(), controller.signal);
const image = images.at(-1);
assert.equal(image.src, 'blob:test');
if (action === 'load') {
image.onload();
assert.equal(await pending, image);
} else if (action === 'error') {
image.onerror();
await assert.rejects(pending, /decode failed/);
} else {
controller.abort();
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(image.src, '');
}
assert.equal(image.onload, null);
assert.equal(image.onerror, null);
}
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
});
+50
View File
@@ -0,0 +1,50 @@
/** Tile a bounded decoded canvas without network requests or scene state. */
export function createRasterTileProvider({
cesium,
texture,
credit,
createCanvas,
rectangle = cesium.Rectangle.MAX_VALUE,
tilingScheme = new cesium.GeographicTilingScheme({ rectangle }),
maximumLevel = 2,
tileSize = 256,
}) {
return {
tilingScheme,
rectangle,
tileWidth: tileSize,
tileHeight: tileSize,
minimumLevel: 0,
maximumLevel,
ready: true,
tileDiscardPolicy: undefined,
credit,
errorEvent: new cesium.Event(),
hasAlphaChannel: true,
getTileCredits: () => undefined,
pickFeatures: () => undefined,
requestImage(x, y, level) {
const tile = createCanvas();
tile.width = tile.height = tileSize;
const ctx = tile.getContext('2d');
const width =
texture.width / tilingScheme.getNumberOfXTilesAtLevel(level);
const height =
texture.height / tilingScheme.getNumberOfYTilesAtLevel(level);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(
texture,
x * width,
y * height,
width,
height,
0,
0,
tileSize,
tileSize,
);
// ImageryLayer consumes requestImage results as promises.
return Promise.resolve(tile);
},
};
}
+156
View File
@@ -0,0 +1,156 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createRasterTileProvider } from './rasterTiles.js';
import { createFieldRaster } from '../wind/fields.js';
// A small canvas double exposes the uploaded raster and exact drawImage crop.
// Verify crop coordinates and source-cell selection independently of canvas smoothing.
function createCanvas() {
const canvas = { width: 0, height: 0 };
const context = {
createImageData: (width, height) => ({
data: new Uint8ClampedArray(width * height * 4),
}),
putImageData(pixels) {
canvas.rgba = pixels.data;
},
drawImage(source, ...args) {
canvas.source = source;
canvas.crop = args;
},
getImageData(x, y) {
const [sx, sy, sw, sh, dx, dy, dw, dh] = canvas.crop;
const col = Math.floor(sx + ((x - dx + 0.5) * sw) / dw);
const row = Math.floor(sy + ((y - dy + 0.5) * sh) / dh);
const offset = (row * canvas.source.width + col) * 4;
return { data: canvas.source.rgba.slice(offset, offset + 4) };
},
};
canvas.getContext = () => context;
return canvas;
}
test('real Cesium geographic provider crops level 0/1 tiles from a decoded canvas with preserved pixels', async () => {
const raster = createFieldRaster(
{
nx: 8,
ny: 3,
lo1: -180,
la1: 90,
dx: 45,
dy: 90,
u: Float32Array.from([
0, 0, 10, 10, 20, 20, 30, 30, 0, 0, 10, 10, 20, 20, 30, 30, 30, 30, 20,
20, 10, 10, 0, 0,
]),
v: new Float32Array(24),
},
'speed',
720,
362,
);
const credit = new Cesium.Credit('NOAA GFS');
const texture = {
width: raster.width,
height: raster.height,
rgba: raster.rgba,
};
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
credit,
createCanvas,
});
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
assert.equal(provider.rectangle, Cesium.Rectangle.MAX_VALUE);
assert.equal(provider.minimumLevel, 0);
assert.equal(provider.maximumLevel, 2);
assert.equal(provider.tileWidth, 256);
assert.equal(provider.tileHeight, 256);
assert.equal(provider.ready, true);
assert.equal(provider.hasAlphaChannel, true);
assert.equal(provider.credit, credit);
assert.ok(provider.errorEvent instanceof Cesium.Event);
assert.equal(provider.tileDiscardPolicy, undefined);
assert.equal(provider.getTileCredits(0, 0, 0), undefined);
assert.equal(provider.pickFeatures(0, 0, 0, 0, 0), undefined);
for (const [x, y, level, crop, cell] of [
[0, 0, 0, [0, 0, 360, 362, 0, 0, 256, 256], [90, 91]],
[1, 0, 0, [360, 0, 360, 362, 0, 0, 256, 256], [450, 91]],
[0, 0, 1, [0, 0, 180, 181, 0, 0, 256, 256], [45, 45]],
[3, 1, 1, [540, 181, 180, 181, 0, 0, 256, 256], [585, 226]],
]) {
const pending = provider.requestImage(x, y, level);
assert.ok(pending instanceof Promise, 'ImageryLayer consumes a promise');
const tile = await pending;
assert.equal(tile.width, 256);
assert.equal(tile.height, 256);
assert.equal(tile.getContext('2d').imageSmoothingEnabled, true);
assert.deepEqual(tile.crop, crop);
const offset = (cell[1] * 720 + cell[0]) * 4;
assert.deepEqual(
tile.getContext('2d').getImageData(64, 64).data,
raster.rgba.slice(offset, offset + 4),
);
}
});
test('bounded decoded mosaic crops both roots and higher levels without resampling its extent', async () => {
const texture = { width: 2048, height: 1024 };
const rectangle = Cesium.Rectangle.fromDegrees(-180, -60, 180, 60);
const scheme = new Cesium.GeographicTilingScheme({
rectangle,
numberOfLevelZeroTilesX: 2,
numberOfLevelZeroTilesY: 1,
});
const credit = new Cesium.Credit('NOAA');
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
rectangle,
tilingScheme: scheme,
maximumLevel: 3,
credit,
createCanvas,
});
assert.equal(provider.rectangle, rectangle);
assert.equal(provider.maximumLevel, 3);
assert.equal(provider.credit, credit);
assert.equal(provider.hasAlphaChannel, true);
for (const [x, y, level, crop] of [
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 256, 256]],
[1, 0, 0, [1024, 0, 1024, 1024, 0, 0, 256, 256]],
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 256, 256]],
]) {
const pending = provider.requestImage(x, y, level);
assert.ok(pending instanceof Promise);
const tile = await pending;
assert.equal(tile.source, texture);
assert.deepEqual(tile.crop, crop);
}
});
test('512-pixel crops preserve source extent at root and clamped draping levels', async () => {
const texture = { width: 2048, height: 1024 };
const provider = createRasterTileProvider({
cesium: Cesium,
texture,
createCanvas,
tileSize: 512,
maximumLevel: 3,
});
assert.equal(provider.tileWidth, 512);
assert.equal(provider.tileHeight, 512);
for (const [x, y, level, crop] of [
[0, 0, 0, [0, 0, 1024, 1024, 0, 0, 512, 512]],
[3, 1, 1, [1536, 512, 512, 512, 0, 0, 512, 512]],
[7, 3, 2, [1792, 768, 256, 256, 0, 0, 512, 512]],
]) {
const tile = await provider.requestImage(x, y, level);
assert.equal(tile.width, 512);
assert.equal(tile.height, 512);
assert.equal(tile.source, texture);
assert.deepEqual(tile.crop, crop);
}
});
+673
View File
@@ -0,0 +1,673 @@
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) => {
// Cesium decodes tiles as ImageBitmaps already flipped, since WebGL
// ignores UNPACK_FLIP_Y for them; a canvas upload flips again.
if (!frame.closed && snapshot.product === 'clouds-regional')
image = processInfraredImage(image, infrared, createCanvas, {
flipY:
typeof ImageBitmap !== 'undefined' &&
image instanceof ImageBitmap,
});
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;
},
};
}
+156
View File
@@ -0,0 +1,156 @@
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,
Ellipsoid: Cesium.Ellipsoid,
Matrix4: Cesium.Matrix4,
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();
}
}
+892
View File
@@ -0,0 +1,892 @@
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();
// From far away Google's coarse 3D tiles rise tens of kilometres above the
// ground and hide the shells, so the shells rise with the camera: 4 m per
// kilometre of camera height, at most 60 km, in 500 m steps.
const LIFT_PER_METRE = 0.004;
const MAX_LIFT = 60_000;
const LIFT_STEP = 500;
// 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;
/** Metres every shell rises for a camera this high above the ellipsoid. */
export function shellLift(cameraHeight) {
if (!(cameraHeight > 0)) return 0;
const lift = Math.min(MAX_LIFT, cameraHeight * LIFT_PER_METRE);
return Math.round(lift / LIFT_STEP) * LIFT_STEP;
}
/** 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);
// A uniform scale about the Earth's centre lifts every shell alike and keeps
// their order.
let lift = 0;
const offLift = scene.preRender?.addEventListener(() => {
const next = shellLift(scene.camera?.positionCartographic?.height);
if (next === lift) return;
lift = next;
primitive.modelMatrix = cesium.Matrix4.fromUniformScale(
1 + lift / cesium.Ellipsoid.WGS84.maximumRadius,
);
});
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;
offLift?.();
if (!primitives.isDestroyed?.() && primitives.contains(primitive))
primitives.remove(primitive);
if (!primitive.isDestroyed()) primitive.destroy();
// Primitive.destroy leaves the material and its texture alive.
if (!material.isDestroyed()) material.destroy();
image = null;
detail = null;
pendingWindow = null;
scene.requestRender();
},
getDiagnostics() {
return {
height,
ready: Boolean(primitive.ready),
uploaded: uploaded(),
show: primitive.show,
alpha: material.uniforms.alpha,
rendering: offRender !== null,
// `uploaded` once the detail image has drawn; the window applies after.
detail: { uploaded: detailDrawn(), window: appliedWindow() },
};
},
};
}
function fitTexture(cesium, { width, height }) {
const limit = cesium.ContextLimits?.maximumTextureSize;
// Halve, down to 1024 px wide, on devices with a smaller texture limit.
while (limit > 0 && width > limit && width > 1024) {
width /= 2;
height /= 2;
}
return { width, height };
}
/** The detail window for a view footprint over product bounds (both in degrees;
* a footprint across the antimeridian has west > east), or null when the
* full-extent image is already the best available. The window is centred on
* the footprint, max(2 × its longitude span, 6°) wide and half as tall, with
* edges on a 0.5° grid inside the bounds. It is enabled only when narrower than
* half the product's longitude extent and when the footprint overlaps the
* product. `previous` is returned unchanged while the footprint centre stays in
* its inner half and the wanted width is within 50 % of its width. */
export function detailWindow(footprint, bounds, previous = null) {
if (!footprint || !bounds) return null;
const across = footprint.east < footprint.west;
const span = footprint.east - footprint.west + (across ? 360 : 0);
let lon = footprint.west + span / 2;
if (lon > 180) lon -= 360;
const lat = (footprint.south + footprint.north) / 2;
const wanted = Math.max(2 * span, DETAIL_MIN_WIDTH);
if (previous) {
const width = previous.east - previous.west;
const height = previous.north - previous.south;
if (
Math.abs(lon - (previous.west + previous.east) / 2) <= width / 4 &&
Math.abs(lat - (previous.south + previous.north) / 2) <= height / 4 &&
Math.abs(wanted - width) <= width / 2
)
return previous;
}
const inner = {
west: Math.ceil(bounds.west / DETAIL_GRID) * DETAIL_GRID,
south: Math.ceil(bounds.south / DETAIL_GRID) * DETAIL_GRID,
east: Math.floor(bounds.east / DETAIL_GRID) * DETAIL_GRID,
north: Math.floor(bounds.north / DETAIL_GRID) * DETAIL_GRID,
};
// Whole degrees keep both the edges and the 2:1 height on the grid; the
// tolerance absorbs radian round trips.
const width = Math.ceil(wanted - 1e-9);
const height = width / 2;
const overlaps =
footprint.south < bounds.north &&
footprint.north > bounds.south &&
(across
? footprint.west < bounds.east || footprint.east > bounds.west
: footprint.west < bounds.east && footprint.east > bounds.west);
if (
!overlaps ||
width >= (bounds.east - bounds.west) / 2 ||
width > inner.east - inner.west ||
height > inner.north - inner.south
)
return null;
const snap = (value) => Math.round(value / DETAIL_GRID) * DETAIL_GRID;
const west = Math.min(
Math.max(snap(lon - width / 2), inner.west),
inner.east - width,
);
const south = Math.min(
Math.max(snap(lat - height / 2), inner.south),
inner.north - height,
);
const next = { west, south, east: west + width, north: south + height };
return previous && sameEdges(previous, next) ? previous : next;
}
/** Observed weather on 3D Tiles: one raised shell per product and one
* full-extent image per frame. The previous image stays until the next is ready.
* Inside a window around the view the same surface samples a sharper image of
* that area, so the two images share one mesh and never blend. */
export function createWeatherShell({
viewer,
cesium,
product,
height = WEATHER_SHELL_HEIGHTS[product],
getHost = () => ({ collection: null, kind: 'tileset' }),
onChange = () => {},
timeoutMs = 25_000,
now = () => performance.now(),
fetchImpl = (...args) => globalThis.fetch(...args),
decodeImage,
createCanvas = () => document.createElement('canvas'),
cacheBytes = WEATHER_SHELL_CACHE_BYTES,
}) {
if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product))
throw new TypeError('Unknown weather product');
const scene = viewer.scene;
const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]);
const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE);
const infrared = product === 'clouds' || product === 'clouds-regional';
// Global infrared contrast depends on the requested extent, so a window would
// not match the image around it; the globe host also shows one mosaic.
const detailed = product !== 'clouds';
let surface = null;
let current = null;
let incoming = null;
let alpha = 0.7;
let hidden = false;
let lastError = null;
const images = new Map();
let imageBytes = 0;
let prefetchJob = null;
let prefetchedKey = null;
// The detail window for the shown bounds, the image drawn in it and its fetch.
let view = null;
let viewBounds = null;
let detail = null;
let detailJob = null;
let offCamera = null;
const suspended = () => imageryHostStatus(getHost()) !== null;
const visible = () => !hidden && !suspended();
const boundsKey = ({ west, south, east, north }) =>
`${west},${south},${east},${north}`;
const cacheKey = (time, mode, box = null) =>
`${time}|${infrared ? mode : 'none'}${box ? `|${boundsKey(box)}` : ''}`;
const wantedDetail = () =>
current && view ? cacheKey(current.time, current.infrared, view) : null;
function evict(keep = []) {
const kept = new Set([...keep, current?.key, detail?.key]);
for (const [key, entry] of images) {
if (imageBytes <= cacheBytes) return;
if (kept.has(key)) continue;
images.delete(key);
imageBytes -= entry.bytes;
}
}
async function acquire(
time,
mode,
signal,
{ box = null, keep = [], onFetched } = {},
) {
signal.throwIfAborted();
const key = cacheKey(time, mode, box);
const entry = images.get(key);
if (entry) {
images.delete(key);
images.set(key, entry);
return { texture: entry.image, decodeMs: 0, cached: true };
}
const result = await acquireWeatherImage(product, time, {
signal,
mode,
size: box ? detailSize : size,
bbox: box,
maxBytes: MAX_IMAGE_BYTES,
createCanvas,
fetchImpl,
decodeImage,
now,
onFetched,
});
// An aborted decode may still finish; never repopulate a cleared instance.
signal.throwIfAborted();
const previous = images.get(key);
if (previous) imageBytes -= previous.bytes;
images.delete(key);
const bytes = result.texture.width * result.texture.height * 4;
images.set(key, { image: result.texture, bytes });
imageBytes += bytes;
evict([key, ...keep]);
return { ...result, cached: false };
}
/** The detail box in the full-extent image's texture coordinates. */
function detailRect() {
const b = current.extent;
const w = detail.box;
const x = b.east - b.west;
const y = b.north - b.south;
return {
west: (w.west - b.west) / x,
south: (w.south - b.south) / y,
east: (w.east - b.west) / x,
north: (w.north - b.south) / y,
};
}
function syncDetail() {
if (detail && current) surface?.setDetail(detail.image, detailRect());
else surface?.setDetail(null);
}
function applyVisibility() {
return surface?.setShow(visible()) ?? false;
}
function cancelDetail() {
clearTimeout(detailJob?.timeout);
detailJob?.controller.abort();
detailJob = null;
}
function dropDetail() {
detail = null;
// The full-extent image covers the window in the same turn.
surface?.setDetail(null);
}
function installDetail(job, image) {
if (detailJob !== job) return;
clearTimeout(job.timeout);
detailJob = null;
detail = { box: job.box, key: job.key, image, stale: false };
syncDetail();
evict();
scene.requestRender();
}
function failDetail(job) {
if (detailJob !== job) return;
clearTimeout(job.timeout);
detailJob = null;
if (detail?.stale) dropDetail();
scene.requestRender();
}
/** Bring the detail to the shown frame and window. The previous detail image
* stays until the next is decoded unless the window itself moved, and over at
* most one newer frame (see commit). */
function refreshDetail() {
const key = wantedDetail();
if (!key) {
cancelDetail();
dropDetail();
return;
}
if (detail && boundsKey(detail.box) !== boundsKey(view)) dropDetail();
syncDetail();
if (detail?.key === key) {
detail.stale = false;
cancelDetail();
return;
}
// Another frame's detail: the next commit drops it unless replaced first.
if (detail) detail.stale = true;
if (!visible()) {
cancelDetail();
return;
}
if (detailJob?.key === key) return;
cancelDetail();
const job = { key, box: view, controller: new AbortController() };
detailJob = job;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
void acquire(current.time, current.infrared, job.controller.signal, {
box: view,
})
.then(({ texture }) => installDetail(job, texture))
.catch(() => failDetail(job));
}
/** Recompute the window from the camera's ground footprint; returns whether it changed. */
function computeView() {
const rectangle = detailed
? viewer.camera?.computeViewRectangle?.(scene.globe?.ellipsoid)
: null;
const degrees = cesium.Math.toDegrees;
const footprint = rectangle && {
west: degrees(rectangle.west),
south: degrees(rectangle.south),
east: degrees(rectangle.east),
north: degrees(rectangle.north),
};
const next = detailWindow(
footprint,
current.extent,
viewBounds === current.bounds ? view : null,
);
viewBounds = current.bounds;
if (next === view) return false;
view = next;
return true;
}
function watchCamera() {
if (offCamera || !detailed) return;
offCamera =
viewer.camera?.moveEnd?.addEventListener(() => {
if (current && computeView()) refreshDetail();
}) ?? null;
}
function cancelPrefetch() {
clearTimeout(prefetchJob?.timeout);
prefetchJob?.controller.abort();
prefetchJob = null;
prefetchedKey = null;
}
function watch(frame, signal) {
if (!signal) return;
frame.owned = true;
const abort = () => {
if (incoming === frame) cancelIncoming();
scene.requestRender();
};
signal.addEventListener('abort', abort, { once: true });
frame.offAbort = () => signal.removeEventListener('abort', abort);
}
function close(frame) {
frame.closed = true;
frame.controller.abort();
clearTimeout(frame.timeout);
frame.offAbort?.();
frame.surface?.destroy();
frame.surface = null;
}
function cancelIncoming() {
if (!incoming) return;
const frame = incoming;
incoming = null;
close(frame);
frame.resolve(false);
}
function fail(frame) {
if (incoming !== frame) return;
incoming = null;
close(frame);
lastError = 'Weather tiles unavailable · previous frame retained';
frame.resolve(false);
scene.requestRender();
onChange();
}
function commit(frame) {
if (incoming !== frame) return;
incoming = null;
clearTimeout(frame.timeout);
frame.offAbort?.();
if (frame.surface) {
surface?.destroy();
surface = frame.surface;
frame.surface = null;
}
const { west, south, east, north } = frame.snapshot.bounds;
current = {
time: frame.time,
product: frame.product,
infrared: frame.infrared,
bounds: frame.bounds,
extent: { west, south, east, north },
key: frame.key,
mosaic: frame.mosaic,
loadMs: now() - frame.startedAt,
};
surface.setAlpha(alpha);
applyVisibility();
lastError = null;
evict();
// The full-extent image swaps first; the detail follows for the same time.
watchCamera();
if (viewBounds !== current.bounds) computeView();
// An older frame's detail stays over at most one newer frame.
if (detail?.stale && detail.key !== wantedDetail()) dropDetail();
refreshDetail();
frame.resolve(true);
scene.requestRender();
onChange();
}
function install(frame, image) {
if (frame.closed || incoming !== frame) return;
if (surface?.bounds === frame.bounds) {
// Cesium keeps drawing the previous texture until this one is uploaded.
surface.setImage(image);
commit(frame);
return;
}
// First frame or a new extent: stage an invisible surface and keep the
// previous one until the new geometry and texture are drawable.
const { west, south, east, north } = frame.snapshot.bounds;
const next = createShellSurface({
viewer,
cesium,
height,
rectangle: cesium.Rectangle.fromDegrees(west, south, east, north),
onSettled: () => commit(frame),
});
next.bounds = frame.bounds;
frame.surface = next;
next.setAlpha(0);
next.setImage(image);
}
return {
product,
rehome() {
if (suspended()) {
cancelPrefetch();
cancelIncoming();
cancelDetail();
}
const changed = applyVisibility();
if (current && !suspended()) {
computeView();
refreshDetail();
}
if (changed) scene.requestRender();
return changed;
},
cancelPrefetch,
async prefetch(snapshot, time, { infrared: mode = 'filtered' } = {}) {
let job;
try {
if (
hidden ||
incoming ||
suspended() ||
snapshot.product !== product ||
!snapshot.times.includes(time)
)
return false;
// Warm the next frame's detail window too while the window stays put.
const box =
view && viewBounds === boundsKey(snapshot.bounds) ? view : null;
const coarse = cacheKey(time, mode);
const key = box ? cacheKey(time, mode, box) : coarse;
if (prefetchJob?.key === key || prefetchedKey === key) return false;
cancelPrefetch();
job = { key, controller: new AbortController() };
prefetchJob = job;
const { signal } = job.controller;
job.timeout = setTimeout(() => job.controller.abort(), timeoutMs);
// Both images at once: one after the other would outlast playback's step.
await Promise.all([
acquire(time, mode, signal, { keep: box ? [key] : [] }),
box ? acquire(time, mode, signal, { box, keep: [coarse] }) : null,
]);
signal.throwIfAborted();
if (prefetchJob === job) prefetchedKey = key;
return true;
} catch {
job?.controller.abort();
return false;
} finally {
clearTimeout(job?.timeout);
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(
snapshot,
time,
{ signal, infrared: mode = 'filtered' } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
const bounds = boundsKey(snapshot.bounds);
const same = (frame) =>
frame?.time === time &&
frame.product === snapshot.product &&
frame.infrared === mode &&
frame.bounds === bounds;
// A request for the frame a host switch is restaging joins that work.
if (same(incoming) && !incoming.owned && !suspended()) {
watch(incoming, signal);
return incoming.result;
}
cancelIncoming();
applyVisibility();
if (suspended()) return false;
if (same(current)) return true;
lastError = null;
const frame = {
snapshot,
time,
infrared: mode,
bounds,
product: snapshot.product,
key: cacheKey(time, mode),
mosaic: { fetched: false, decodeMs: null, cached: false },
controller: new AbortController(),
startedAt: now(),
surface: null,
owned: false,
closed: false,
resolve: null,
};
incoming = frame;
frame.result = new Promise((resolve) => {
frame.resolve = resolve;
});
watch(frame, signal);
frame.timeout = setTimeout(() => fail(frame), timeoutMs);
void acquire(time, mode, frame.controller.signal, {
onFetched: () => {
frame.mosaic.fetched = true;
},
})
.then(({ texture, decodeMs, cached }) => {
frame.mosaic.decodeMs = decodeMs;
frame.mosaic.cached = cached;
install(frame, texture);
})
.catch(() => fail(frame));
scene.requestRender();
onChange();
return frame.result;
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) {
cancelPrefetch();
cancelIncoming();
cancelDetail();
}
applyVisibility();
if (!hidden) refreshDetail();
scene.requestRender();
},
setAlpha(value) {
alpha = value;
surface?.setAlpha(alpha);
scene.requestRender();
},
clear() {
cancelPrefetch();
cancelIncoming();
cancelDetail();
offCamera?.();
offCamera = null;
dropDetail();
surface?.destroy();
surface = null;
current = null;
view = null;
viewBounds = null;
hidden = false;
lastError = null;
images.clear();
imageBytes = 0;
scene.requestRender();
},
getDiagnostics() {
const drawn = surface?.getDiagnostics();
const applied = drawn?.detail.window;
return {
host: 'shell',
height,
imageSize: { ...size },
shell: drawn
? {
...drawn,
detail: {
bbox: view
? [view.west, view.south, view.east, view.north]
: null,
size: { ...detailSize },
// The wanted window is applied and its image drawn.
ready: Boolean(
detail &&
current &&
detail.key === wantedDetail() &&
drawn.ready &&
drawn.show &&
drawn.detail.uploaded &&
applied &&
sameEdges(applied, detailRect()),
),
enabled: view !== null,
},
}
: null,
cache: {
mosaics: images.size,
bytes: imageBytes,
prefetching: !!prefetchJob,
},
imageryCount: Number(!!surface) + Number(!!incoming?.surface),
mosaic: (incoming || current)?.mosaic,
infrared: (incoming || current)?.infrared ?? 'filtered',
loading: !!incoming,
time: hidden ? null : (current?.time ?? null),
hidden,
product: current?.product ?? null,
frameLoadMs: current?.loadMs ?? null,
error: imageryHostStatus(getHost()) || lastError,
};
},
};
}
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
export const WEATHER_PRODUCTS = Object.freeze([
'radar',
'clouds',
'clouds-regional',
'lightning',
]);
/** Only bounded, explicit observations may become imagery requests. */
export function validateWeatherSnapshot(value, product) {
if (!value || value.schemaVersion !== 1 || value.product !== product)
throw new Error('Malformed weather manifest');
if (value.unavailable) return value;
const { bounds, times } = value;
if (
!bounds ||
!['west', 'south', 'east', 'north'].every((key) =>
Number.isFinite(bounds[key]),
) ||
bounds.west < -180 ||
bounds.east > 180 ||
bounds.south < -90 ||
bounds.north > 90 ||
bounds.west >= bounds.east ||
bounds.south >= bounds.north ||
!Array.isArray(times) ||
times.length < 1 ||
times.length > 13 ||
times.some(
(time, i) =>
typeof time !== 'string' ||
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(time) ||
!Number.isFinite(Date.parse(time)) ||
new Date(time).toISOString() !== time ||
(i > 0 && time <= times[i - 1]),
) ||
value.latest !== times.at(-1) ||
value.tileSize !== 256 ||
value.maxLevel !== 6 ||
value.tilingScheme !== 'geographic'
)
throw new Error('Malformed weather manifest');
return value;
}
/** Largest whole-extent image per product; also the proxy default size. */
export const WEATHER_IMAGE_SIZES = Object.freeze({
radar: Object.freeze({ width: 4096, height: 2048 }),
'clouds-regional': Object.freeze({ width: 4096, height: 2048 }),
clouds: Object.freeze({ width: 2048, height: 1024 }),
lightning: Object.freeze({ width: 4096, height: 2048 }),
});
/** Largest detail-window image for every product; also the proxy default. */
export const WEATHER_DETAIL_SIZE = Object.freeze({ width: 4096, height: 2048 });
/** A whole-extent image, or with `bbox` ({ west, south, east, north } degrees)
* a detail window of the same product. */
export function weatherImageUrl(
product,
time,
{ width, height } = {},
bbox = null,
) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
let box = '';
if (bbox !== null) {
const edges = [bbox.west, bbox.south, bbox.east, bbox.north];
if (
!edges.every(Number.isFinite) ||
edges[0] >= edges[2] ||
edges[1] >= edges[3]
)
throw new Error('Invalid weather window');
box = `&bbox=${edges.join(',')}`;
}
const largest =
bbox === null ? WEATHER_IMAGE_SIZES[product] : WEATHER_DETAIL_SIZE;
let size = '';
if (width !== undefined || height !== undefined) {
if (
![1024, 2048, 4096].includes(width) ||
height !== width / 2 ||
width > largest.width
)
throw new Error('Invalid weather image size');
// The largest size is the proxy default: one frame has one URL.
if (width !== largest.width) size = `&size=${width}x${height}`;
}
return `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}${box}${size}`;
}
export function weatherTileUrl(product, time, { size } = {}) {
if (!WEATHER_PRODUCTS.includes(product) || !Number.isFinite(Date.parse(time)))
throw new Error('Invalid weather frame');
if (size !== undefined && ![256, 512, 1024].includes(size))
throw new Error('Invalid weather tile size');
// Construct locally; never accept a manifest-provided host or template.
return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`;
}
/** Acquisition is lazy and shares the application's existing source contract. */
export function createWeatherSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 15_000,
} = {}) {
return {
async getSnapshot({ product = 'radar', signal } = {}) {
if (!WEATHER_PRODUCTS.includes(product))
throw new Error('Unknown weather product');
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Weather request timed out')),
timeoutMs,
);
try {
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/weather/manifest?product=${product}`,
{ signal: controller.signal, cache: 'no-store', redirect: 'error' },
);
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
return validateWeatherSnapshot(
await readResponseJsonCapped(response, 16_384, controller.signal),
product,
);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWeatherSource,
validateWeatherSnapshot,
weatherImageUrl,
weatherTileUrl,
WEATHER_DETAIL_SIZE,
WEATHER_IMAGE_SIZES,
} from './source.js';
const time = '2026-09-16T02:00:00.000Z';
const snapshot = () => ({
schemaVersion: 1,
product: 'radar',
bounds: { west: -130, south: 20, east: -60, north: 55 },
times: [time],
latest: time,
tileSize: 256,
maxLevel: 6,
tilingScheme: 'geographic',
});
test('observed source accepts bounded exact times and refuses malformed or unsorted frames', () => {
assert.equal(validateWeatherSnapshot(snapshot(), 'radar').latest, time);
for (const bad of [
{ times: [time, time] },
{ product: 'other' },
{ times: Array(14).fill(time) },
{ latest: 'tomorrow' },
{ maxLevel: 20 },
{ bounds: { west: -999, south: 0, east: 1, north: 1 } },
])
assert.throws(
() => validateWeatherSnapshot({ ...snapshot(), ...bad }, 'radar'),
/Malformed/,
);
});
test('weather tile URLs are same origin and ignore upstream templates', async () => {
const calls = [];
const source = createWeatherSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return Response.json({
...snapshot(),
tileTemplate: 'https://example.invalid/{z}',
});
},
});
const result = await source.getSnapshot({ product: 'radar' });
assert.match(
weatherTileUrl(result.product, result.latest),
/^\/api\/weather\/tile\?product=radar&time=2026/,
);
assert.equal(calls[0].options.redirect, 'error');
assert.equal(calls[0].url, '/api/weather/manifest?product=radar');
});
test('source aborts before acquisition and caps streamed manifest bytes', async () => {
let calls = 0;
const source = createWeatherSource({
fetchImpl: async () => {
calls++;
return new Response(' '.repeat(17000));
},
});
await assert.rejects(source.getSnapshot({ signal: AbortSignal.abort() }), {
name: 'AbortError',
});
assert.equal(calls, 0);
await assert.rejects(source.getSnapshot(), /too large/);
});
test('weather tile URLs carry only supported optional pixel sizes', () => {
assert.equal(
new URL(
weatherTileUrl('radar', time),
'https://example.test',
).searchParams.has('size'),
false,
);
for (const size of [256, 512, 1024]) {
const url = new URL(
weatherTileUrl('lightning', time, { size }),
'https://example.test',
);
assert.equal(url.searchParams.get('size'), String(size));
assert.equal(url.searchParams.get('time'), time);
}
for (const size of [0, 257, 2048, '1024', null])
assert.throws(() => weatherTileUrl('radar', time, { size }), /tile size/);
});
test('whole-extent image URLs are same origin and omit the default largest size', () => {
for (const [product, width] of [
['radar', 4096],
['clouds-regional', 4096],
['clouds', 2048],
['lightning', 4096],
]) {
assert.deepEqual(WEATHER_IMAGE_SIZES[product], {
width,
height: width / 2,
});
assert.equal(
weatherImageUrl(product, time),
`/api/weather/image?product=${product}&time=${encodeURIComponent(time)}`,
);
assert.equal(
weatherImageUrl(product, time, { width, height: width / 2 }),
weatherImageUrl(product, time),
);
assert.equal(
new URL(
weatherImageUrl(product, time, { width: 1024, height: 512 }),
'https://example.test',
).searchParams.get('size'),
'1024x512',
);
}
for (const size of [
{ width: 8192, height: 4096 },
{ width: 2048, height: 2048 },
{ width: 512, height: 256 },
{ width: '1024', height: 512 },
])
assert.throws(() => weatherImageUrl('lightning', time, size), /size/);
assert.throws(
() => weatherImageUrl('clouds', time, { width: 4096, height: 2048 }),
/size/,
);
assert.throws(() => weatherImageUrl('other', time), /frame/);
assert.throws(() => weatherImageUrl('radar', 'latest'), /frame/);
});
test('detail-window image URLs carry the bbox and omit the default detail size', () => {
const box = { west: -102, south: 34, east: -96, north: 37 };
assert.deepEqual(WEATHER_DETAIL_SIZE, { width: 4096, height: 2048 });
for (const product of ['radar', 'clouds-regional', 'lightning', 'clouds']) {
const url = `/api/weather/image?product=${product}&time=${encodeURIComponent(time)}&bbox=-102,34,-96,37`;
assert.equal(weatherImageUrl(product, time, {}, box), url);
assert.equal(
weatherImageUrl(product, time, { width: 4096, height: 2048 }, box),
url,
'every product has a 4096×2048 detail default',
);
assert.equal(
weatherImageUrl(product, time, { width: 2048, height: 1024 }, box),
`${url}&size=2048x1024`,
);
}
assert.equal(
weatherImageUrl('radar', time, {}, { ...box, west: -97.5, east: -91.5 }),
`/api/weather/image?product=radar&time=${encodeURIComponent(time)}&bbox=-97.5,34,-91.5,37`,
);
for (const bad of [
{ ...box, west: -96 },
{ ...box, north: 34 },
{ ...box, east: Number.NaN },
{ west: -102, south: 34, east: -96 },
])
assert.throws(() => weatherImageUrl('radar', time, {}, bad), /window/);
assert.throws(
() => weatherImageUrl('radar', time, { width: 8192, height: 4096 }, box),
/size/,
);
});
File diff suppressed because it is too large. Load diff
+171
View File
@@ -0,0 +1,171 @@
import { normalizeLongitude, sampleWind } from './model.js';
/** Fixed physical scales shared by imagery and the field legend. */
export const WIND_FIELDS = Object.freeze({
speed: Object.freeze({
label: 'Wind speed',
units: 'm/s',
min: 0,
max: 30,
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
}),
temperature: Object.freeze({
label: '2 m air temperature',
units: '°C',
min: -40,
max: 50,
// Fixed 10°C anchors keep model comparisons honest while making everyday
// temperature gradients distinguishable from the underlying basemap.
stops: [
'#31235b',
'#403b92',
'#315fc1',
'#268fce',
'#31bdd0',
'#8ad4aa',
'#f0d255',
'#ee8a34',
'#d74638',
'#9f274d',
],
}),
pressure: Object.freeze({
label: 'Mean sea-level pressure',
units: 'hPa',
min: 960,
max: 1050,
stops: ['#705ca0', '#6985b4', '#a5c3cd', '#d8c99a', '#dbad61'],
}),
});
function palette(stops) {
const rgb = stops.map((color) =>
color.match(/\w\w/g).map((value) => parseInt(value, 16)),
);
return Array.from({ length: 128 }, (_, i) => {
const p = (i / 127) * (rgb.length - 1);
const a = Math.min(rgb.length - 2, Math.floor(p));
return rgb[a].map((value, channel) =>
Math.round(value + (rgb[a + 1][channel] - value) * (p - a)),
);
});
}
const PALETTES = Object.fromEntries(
Object.entries(WIND_FIELDS).map(([kind, spec]) => [
kind,
palette(spec.stops),
]),
);
const WIND_COLORS = PALETTES.speed.map((rgb) => `rgb(${rgb.join(',')})`);
const clamp = (x, low, high) => Math.max(low, Math.min(high, x));
/** No parsing, arrays or CSS interpolation in the particle loop. */
export function windTrailColor(speed) {
return WIND_COLORS[
Math.round((clamp(Number.isFinite(speed) ? speed : 0, 0, 30) / 30) * 127)
];
}
/** Decay is a function of elapsed time, independent of refresh rate. */
export function trailEraseAlpha(seconds, halfLife = 0.65) {
return 1 - Math.pow(0.5, Math.max(0, seconds) / halfLife);
}
/** Scalar units are already normalized by the source; do not convert twice. */
export function sampleScalar(
snapshot,
lon,
lat,
kind = snapshot?.scalar?.kind ?? 'speed',
) {
const grid = snapshot?.grid ?? snapshot;
if (!grid || !Number.isFinite(lon) || !Number.isFinite(lat)) return null;
if (kind === 'speed') {
const wind = sampleWind(
grid === snapshot ? grid : { ...grid, u: snapshot.u, v: snapshot.v },
lon,
lat,
);
const speed = Math.hypot(wind.u, wind.v);
return Number.isFinite(speed) ? speed : null;
}
const scalar = snapshot.scalar;
if (
!WIND_FIELDS[kind] ||
scalar?.kind !== kind ||
scalar.units !== WIND_FIELDS[kind].units ||
!scalar.values
)
return null;
const { nx, ny, lo1, la1, dx, dy } = grid;
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
const y = clamp((la1 - lat) / dy, 0, ny - 1);
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const tx = x - x0;
const ty = y - y0;
const col = (n) => ((n % nx) + nx) % nx;
const y1 = Math.min(y0 + 1, ny - 1);
const a = scalar.values[y0 * nx + col(x0)];
const b = scalar.values[y0 * nx + col(x0 + 1)];
const c = scalar.values[y1 * nx + col(x0)];
const d = scalar.values[y1 * nx + col(x0 + 1)];
const value =
(a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
return Number.isFinite(value) ? value : null;
}
/** One bounded geographic texture per installed field, never per render frame. */
export function createFieldRaster(snapshot, kind, width = 360, height = 181) {
const spec = WIND_FIELDS[kind];
if (
!spec ||
!snapshot ||
!Number.isInteger(width) ||
!Number.isInteger(height) ||
width < 2 ||
height < 2 ||
width > 720 ||
height > 362
)
return null;
if (
kind !== 'speed' &&
(snapshot.scalar?.kind !== kind || snapshot.scalar.units !== spec.units)
)
return null;
const rgba = new Uint8ClampedArray(width * height * 4);
const flat = snapshot.grid
? {
...snapshot.grid,
u: snapshot.u,
v: snapshot.v,
scalar: snapshot.scalar,
}
: snapshot;
let finiteCount = 0;
for (let y = 0; y < height; y++) {
const lat = 90 - ((y + 0.5) / height) * 180;
for (let x = 0; x < width; x++) {
// Pixel centers cover -180..180; the source's 0-degree origin is not an image origin.
const lon = -180 + ((x + 0.5) / width) * 360;
const value = sampleScalar(flat, lon, lat, kind);
if (value === null) continue;
const t = clamp((value - spec.min) / (spec.max - spec.min), 0, 1);
const rgb = PALETTES[kind][Math.round(t * 127)];
const offset = (y * width + x) * 4;
rgba[offset] = rgb[0];
rgba[offset + 1] = rgb[1];
rgba[offset + 2] = rgb[2];
const alpha =
kind === 'speed'
? 0.22 + 0.43 * Math.sqrt(t)
: kind === 'pressure'
? 0.24 + 0.3 * Math.min(1, Math.abs(value - 1013) / 40)
: 0.95;
rgba[offset + 3] = Math.round(255 * alpha);
finiteCount++;
}
}
return finiteCount ? { width, height, rgba } : null;
}
+114
View File
@@ -0,0 +1,114 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
WIND_FIELDS,
createFieldRaster,
sampleScalar,
trailEraseAlpha,
windTrailColor,
} from './fields.js';
import { sampleWind } from './model.js';
const snapshot = {
grid: { nx: 4, ny: 2, lo1: 0, la1: 90, dx: 90, dy: 180 },
u: new Float32Array(8).fill(3),
v: new Float32Array(8).fill(4),
scalar: {
kind: 'temperature',
units: '°C',
values: new Float32Array([0, 10, 20, 30, -10, 0, 10, 20]),
},
};
test('scalar samples preserve normalized units, geometry, poles and the date-line seam', () => {
assert.equal(sampleScalar(snapshot, 0, 90, 'speed'), 5);
assert.equal(sampleScalar(snapshot, 0, 90, 'temperature'), 0);
assert.equal(sampleScalar(snapshot, 90, -90, 'temperature'), 0);
assert.equal(sampleScalar(snapshot, 180, 0, 'temperature'), 15);
assert.equal(sampleScalar(snapshot, -180, 0, 'temperature'), 15);
assert.equal(
sampleScalar(snapshot, -0.01, 90, 'temperature'),
sampleScalar(snapshot, 359.99, 90, 'temperature'),
);
assert.equal(sampleScalar(snapshot, 0, 0, 'pressure'), null);
assert.equal(
sampleScalar(
{ ...snapshot, scalar: { ...snapshot.scalar, units: 'K' } },
0,
0,
'temperature',
),
null,
);
assert.equal(sampleScalar(snapshot, Infinity, 0, 'speed'), null);
});
test('raster samples geographic pixel centers, not the source zero-longitude origin', () => {
const raster = createFieldRaster(snapshot, 'temperature', 4, 2);
const shifted = {
...snapshot,
grid: { ...snapshot.grid, lo1: -180 },
scalar: {
...snapshot.scalar,
values: new Float32Array([20, 30, 0, 10, 10, 20, -10, 0]),
},
};
assert.deepEqual(raster, createFieldRaster(shifted, 'temperature', 4, 2));
assert.equal(raster.rgba.length, 32);
assert.notDeepEqual(
[...raster.rgba.slice(0, 3)],
[...raster.rgba.slice(8, 11)],
);
assert.equal(createFieldRaster(snapshot, 'pressure'), null);
assert.equal(createFieldRaster(snapshot, 'speed', 4000, 2000), null);
});
test('trail decay is independent of frame cadence and palette lookup clamps endpoints', () => {
const remaining30 = (1 - trailEraseAlpha(1 / 30)) ** 30;
const remaining60 = (1 - trailEraseAlpha(1 / 60)) ** 60;
assert.ok(Math.abs(remaining30 - remaining60) < 1e-12);
assert.ok(Math.abs(1 - trailEraseAlpha(0.65) - 0.5) < 1e-12);
assert.equal(windTrailColor(-10), windTrailColor(0));
assert.equal(windTrailColor(100), windTrailColor(30));
assert.notEqual(windTrailColor(0), windTrailColor(30));
});
test('hot-loop sampling fills the supplied output without allocating a replacement', () => {
const result = {};
assert.equal(
sampleWind(
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
0,
0,
result,
),
result,
);
assert.deepEqual(result, { u: 3, v: 4 });
});
test('temperature uses a fixed Celsius scale with visible everyday gradients', () => {
const spec = WIND_FIELDS.temperature;
assert.equal(spec.units, '°C');
assert.deepEqual(
spec.stops.map((_, index) => spec.min + index * (spec.max - spec.min) / (spec.stops.length - 1)),
[-40, -30, -20, -10, 0, 10, 20, 30, 40, 50],
);
const colorAt = (temperature) => {
const field = { ...snapshot, scalar: { ...snapshot.scalar, values: new Float32Array(8).fill(temperature) } };
assert.equal(sampleScalar(field, 0, 0, 'temperature'), temperature, 'display mapping does not change measurements');
const raster = createFieldRaster(field, 'temperature', 2, 2);
assert.ok(raster.rgba[3] >= 225, 'temperature color dominates the basemap rather than washing out');
return [...raster.rgba.slice(0, 3)];
};
let previous = colorAt(0);
for (const temperature of [5, 10, 15, 20, 25, 30, 35]) {
const color = colorAt(temperature);
const distance = Math.hypot(...color.map((value, index) => value - previous[index]));
assert.ok(distance >= 30, `five-degree difference near ${temperature}°C remains visually distinct`);
previous = color;
}
assert.deepEqual(colorAt(-60), colorAt(-40), 'only color saturates below the fixed scale');
assert.deepEqual(colorAt(60), colorAt(50), 'only color saturates above the fixed scale');
});
+277
View File
@@ -0,0 +1,277 @@
import {
bakeWindStreamlines,
groupWindPaths,
WIND_DISPLAY_HEIGHT_METERS,
WIND_PATH_LIMIT,
WIND_NARROW_PATH_LIMIT,
} from './streamlines.js';
export const WIND_FADE_LOW_METERS = 15000;
export const WIND_FADE_HIGH_METERS = 60000;
// Original material: a persistent fine curve plus a moving, tapered highlight.
// The integer part of s identifies a path; its fractional part follows the wind.
const FLOW_MATERIAL = `
in float v_windFacing;
czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
float pathId = floor(materialInput.st.s);
float along = fract(materialInput.st.s);
float offset = fract((pathId + 1.0) * 0.61803398875);
float behind = fract(phaseTime * 0.16 + offset - along);
float tail = (1.0 - smoothstep(0.0, 0.32, behind));
float tip = 1.0 - smoothstep(0.0, 0.045, behind);
float ends = smoothstep(0.0, 0.055, along) * (1.0 - smoothstep(0.945, 1.0, along));
float edge = 1.0 - smoothstep(0.26, 0.5, abs(materialInput.st.t - 0.5));
float horizon = smoothstep(0.0, 0.065, v_windFacing);
material.diffuse = mix(vec3(0.50, 0.79, 0.90), vec3(0.91, 0.99, 1.0), tip);
material.alpha = (ghostAlpha + 0.64 * tail + 0.16 * tip) * ends * edge * horizon * heightFade;
return material;
}`;
/** Native Cesium geometry/material owner. No animation loop or DOM ownership. */
export function createWindGpuRendering({ cesium: C, getViewer }) {
const cells = [];
let occluder = null;
let collection = null;
let material = null;
let paused = false;
let destroyed = false;
const emptyDiagnostics = () => ({
pathCount: 0,
vertexCount: 0,
buildMs: 0,
error: null,
cellCount: 0,
visibleCells: 0,
visibleVertexCount: 0,
heightFade: 0,
});
let diagnostics = emptyDiagnostics();
function supported() {
const scene = getViewer?.()?.scene;
return (
!destroyed &&
Boolean(
scene?.primitives &&
C?.Primitive &&
C?.GeometryInstance &&
C?.GeometryInstanceAttribute &&
C?.ComponentDatatype?.FLOAT !== undefined &&
C?.PolylineGeometry &&
C?.PolylineMaterialAppearance &&
C?.Material &&
C?.BoundingSphere?.fromPoints &&
C?.Occluder &&
C?.Ellipsoid?.WGS84 &&
C?.Intersect &&
C?.Cartesian3?.fromDegrees,
) &&
(C.SceneMode?.SCENE3D === undefined ||
scene.mode === undefined ||
scene.mode === C.SceneMode.SCENE3D)
);
}
function clear() {
for (const cell of cells) {
collection?.remove(cell.primitive);
if (!cell.primitive.isDestroyed?.()) cell.primitive.destroy?.();
}
cells.length = 0;
occluder = null;
collection = null;
material?.destroy?.();
material = null;
diagnostics = emptyDiagnostics();
}
function setField(field) {
clear();
if (!supported() || !field) return false;
const started = globalThis.performance?.now?.() ?? Date.now();
try {
const width = getViewer().scene.canvas?.clientWidth;
const budget =
width > 0 && width < 700 ? WIND_NARROW_PATH_LIMIT : WIND_PATH_LIMIT;
const paths = bakeWindStreamlines(field, { count: budget });
if (!paths.length) return false;
let vertexCount = 0;
material = new C.Material({
fabric: {
type: 'GevWindStreamline',
uniforms: {
phaseTime: 0,
ghostAlpha: paused ? 0.34 : 0.25,
heightFade: 0,
},
source: FLOW_MATERIAL,
},
translucent: () => true,
});
const base = new C.PolylineMaterialAppearance({ material });
// Extend the public default shader rather than duplicate Cesium's polyline
// expansion/precision code. Explicit facing also masks the far hemisphere
// when a scene chooses not to preserve globe depth for translucent objects.
const source = base.vertexShaderSource;
if (
!/void\s+main\s*\(\s*\)/.test(source) ||
!/v_st\.s\s*=\s*st\.s\s*;/.test(source)
)
throw new Error('Wind polyline shader entry unavailable');
const seededSource = source.replace(
/v_st\.s\s*=\s*st\.s\s*;/,
'v_st.s = st.s * 0.999 + czm_batchTable_windSeed(batchId);',
);
const vertexShaderSource =
`out float v_windFacing;\n${seededSource}`.replace(
/void\s+main\s*\(\s*\)\s*\{/,
`void main() {\nvec3 windWorld = (czm_model * vec4(position3DHigh + position3DLow, 1.0)).xyz;\nv_windFacing = dot(normalize(windWorld), normalize(czm_viewerPositionWC - windWorld));\n`,
);
const scene = getViewer().scene;
collection = scene.primitives;
// EllipsoidalOccluder only tests points. Use Cesium's sphere occluder
// with an inscribed Earth sphere, independent of scene.globe.show.
occluder = new C.Occluder(
new C.BoundingSphere(
C.Cartesian3.ZERO,
C.Ellipsoid.WGS84.minimumRadius,
),
scene.camera.positionWC,
);
for (const group of groupWindPaths(paths)) {
const cellPositions = [];
let cellVertexCount = 0;
const instances = group.paths.map((path) => {
const positions = path.coordinates.map(([lon, lat]) =>
C.Cartesian3.fromDegrees(lon, lat, WIND_DISPLAY_HEIGHT_METERS),
);
// Pass the native description to Cesium's worker. Per-instance seed
// attributes avoid touching the expanded vertex buffer on the UI thread.
const geometry = new C.PolylineGeometry({
positions,
width: 2.4,
arcType: C.ArcType?.NONE,
vertexFormat: C.PolylineMaterialAppearance.VERTEX_FORMAT,
});
cellPositions.push(...positions);
cellVertexCount += positions.length * 4 - 4;
return new C.GeometryInstance({
geometry,
attributes: {
windSeed: new C.GeometryInstanceAttribute({
componentDatatype: C.ComponentDatatype.FLOAT,
componentsPerAttribute: 1,
value: [path.seed],
}),
},
});
});
const appearance = new C.PolylineMaterialAppearance({
material,
vertexShaderSource,
translucent: true,
renderState: { depthTest: { enabled: true }, depthMask: false },
});
const primitive = new C.Primitive({
show: false,
geometryInstances: instances,
appearance,
asynchronous: true,
allowPicking: false,
releaseGeometryInstances: true,
compressVertices: false,
});
const cell = {
primitive,
sphere: C.BoundingSphere.fromPoints(cellPositions),
vertexCount: cellVertexCount,
};
cells.push(cell);
collection.add(primitive);
vertexCount += cellVertexCount;
}
diagnostics = {
...emptyDiagnostics(),
cellCount: cells.length,
pathCount: paths.length,
vertexCount,
buildMs: (globalThis.performance?.now?.() ?? Date.now()) - started,
error: null,
};
return true;
} catch (error) {
clear();
diagnostics.error =
error instanceof Error ? error.message : 'Wind geometry unavailable';
return false;
}
}
// Cesium reuses its frustum culling volume and the occluder's scratch vectors.
// This loop creates no positions, spheres, appearances or per-cell temporaries.
function updateVisibility(camera) {
const height = camera.positionCartographic.height;
const heightFade = Math.max(
0,
Math.min(
1,
(height - WIND_FADE_LOW_METERS) /
(WIND_FADE_HIGH_METERS - WIND_FADE_LOW_METERS),
),
);
diagnostics.heightFade = heightFade;
if (material) material.uniforms.heightFade = heightFade;
diagnostics.visibleCells = 0;
diagnostics.visibleVertexCount = 0;
let volume;
if (heightFade > 0 && cells.length) {
occluder.cameraPosition = camera.positionWC;
volume = camera.frustum.computeCullingVolume(
camera.positionWC,
camera.directionWC,
camera.upWC,
);
}
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
cell.primitive.show =
heightFade > 0 &&
occluder.isBoundingSphereVisible(cell.sphere) &&
volume.computeVisibility(cell.sphere) !== C.Intersect.OUTSIDE;
if (cell.primitive.show) {
diagnostics.visibleCells++;
diagnostics.visibleVertexCount += cell.vertexCount;
}
}
return diagnostics.visibleCells > 0 && heightFade > 0;
}
return {
supported,
setField,
updateVisibility,
tick(elapsedSeconds) {
if (material && !paused && Number.isFinite(elapsedSeconds))
material.uniforms.phaseTime = Math.max(0, elapsedSeconds) % 10000;
},
setOptions(options = {}) {
if (typeof options.paused === 'boolean') paused = options.paused;
if (material) material.uniforms.ghostAlpha = paused ? 0.34 : 0.25;
},
clear,
destroy() {
clear();
destroyed = true;
},
getParticleCount: () => diagnostics.pathCount,
getDiagnostics: () => ({
...diagnostics,
ready: cells.length > 0 && cells.every((cell) => cell.primitive.ready),
mode: 'gpu-streamlines',
displayHeightMeters: WIND_DISPLAY_HEIGHT_METERS,
}),
};
}
+399
View File
@@ -0,0 +1,399 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindGpuRendering } from './gpuRendering.js';
import {
BoundingSphere,
Cartesian3,
Ellipsoid,
Intersect,
Occluder,
PerspectiveFrustum,
} from 'cesium';
import { bakeWindStreamlines } from './streamlines.js';
function harness({ width = 1000 } = {}) {
const members = new Set();
let geometryBuilds = 0;
const spheres = [];
const visibility = {
horizon: () => true,
frustum: () => Intersect.INSIDE,
horizonTests: 0,
frustumTests: 0,
};
let occluderBuilds = 0;
class Material {
constructor(options) {
this.uniforms = options.fabric.uniforms;
this.source = options.fabric.source;
}
destroy() {
assert.ok(!this.destroyed, 'owned resource destroyed once');
this.destroyed = true;
}
}
class Appearance {
static VERTEX_FORMAT = {};
constructor(options) {
Object.assign(this, options);
this.vertexShaderSource ??=
'in vec3 position3DHigh; in vec3 position3DLow; void main() { gl_Position = vec4(0.0); v_st.s = st.s; }';
}
}
class Polyline {
constructor(options) {
Object.assign(this, options);
}
static createGeometry(options) {
geometryBuilds++;
const st = new Float32Array(options.positions.length * 2);
for (let i = 0; i < options.positions.length; i++)
st[i * 2] = i / (options.positions.length - 1);
return { attributes: { st: { values: st } } };
}
}
class Primitive {
constructor(options) {
Object.assign(this, options);
this.ready = true;
}
destroy() {
assert.ok(!this.destroyed, 'owned resource destroyed once');
this.destroyed = true;
}
isDestroyed() {
return Boolean(this.destroyed);
}
}
const C = {
ComponentDatatype: { FLOAT: 5126 },
GeometryInstanceAttribute: class {
constructor(options) {
Object.assign(this, options);
}
},
Material,
PolylineMaterialAppearance: Appearance,
PolylineGeometry: Polyline,
Primitive,
GeometryInstance: class {
constructor(options) {
Object.assign(this, options);
}
},
Cartesian3,
Ellipsoid,
Intersect,
BoundingSphere: class extends BoundingSphere {
static fromPoints(points) {
const sphere = BoundingSphere.fromPoints(points);
spheres.push({ sphere, points });
return sphere;
}
},
Occluder: class {
constructor(sphere, cameraPosition) {
occluderBuilds++;
this.cameraPosition = cameraPosition;
}
isBoundingSphereVisible(sphere) {
visibility.horizonTests++;
return visibility.horizon(sphere, this.cameraPosition);
}
},
ArcType: { NONE: 0 },
};
const volume = {
computeVisibility(sphere) {
visibility.frustumTests++;
return visibility.frustum(sphere);
},
};
const scene = {
globe: { show: false },
camera: {
positionWC: Cartesian3.fromDegrees(0, 0, 1e7),
positionCartographic: { height: 1e7 },
directionWC: new Cartesian3(-1, 0, 0),
upWC: new Cartesian3(0, 0, 1),
frustum: { computeCullingVolume: () => volume },
},
canvas: { clientWidth: width },
primitives: {
add(item) {
members.add(item);
},
remove(item) {
const removed = members.delete(item);
if (removed) item.destroy();
return removed;
},
},
};
return {
C,
scene,
members,
builds: () => geometryBuilds,
spheres,
visibility,
occluderBuilds: () => occluderBuilds,
owner: createWindGpuRendering({ cesium: C, getViewer: () => ({ scene }) }),
};
}
const field = () => ({
nx: 4,
ny: 3,
lo1: -180,
la1: 90,
dx: 90,
dy: 90,
u: new Float32Array(12).fill(15),
v: new Float32Array(12),
});
test('GPU owner builds regional native batches; ticks update uniforms without rebuilding geometry', () => {
const h = harness();
assert.equal(h.owner.setField(field()), true);
assert.equal(h.members.size, 72);
const batch = [...h.members][0];
assert.equal(batch.allowPicking, false);
assert.equal(batch.asynchronous, true);
assert.ok(batch.appearance.vertexShaderSource.includes('v_windFacing = dot'));
assert.ok(
batch.appearance.material.source.includes(
'smoothstep(0.0, 0.065, v_windFacing)',
),
);
const builds = h.builds();
assert.equal(builds, 0, 'expanded geometry is deferred to Cesium workers');
assert.ok(
batch.appearance.vertexShaderSource.includes(
'czm_batchTable_windSeed(batchId)',
),
);
h.owner.tick(3);
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
h.owner.setOptions({ paused: true });
h.owner.tick(8);
assert.equal(batch.appearance.material.uniforms.phaseTime, 3);
h.owner.setOptions({ paused: false });
h.owner.tick(9);
assert.equal(batch.appearance.material.uniforms.phaseTime, 9);
assert.equal(h.builds(), builds);
const diagnostics = h.owner.getDiagnostics();
assert.ok(diagnostics.pathCount <= 7200);
assert.ok(
diagnostics.pathCount > 7000,
'desktop retains the requested doubled global density',
);
assert.ok(diagnostics.vertexCount > 0);
assert.ok(
diagnostics.vertexCount <= 7200 * 128,
'expanded geometry remains bounded',
);
assert.equal(diagnostics.ready, true);
assert.equal(diagnostics.displayHeightMeters, 12000);
assert.equal(diagnostics.cellCount, 72);
assert.equal(h.spheres.length, 72);
assert.equal(new Set([...h.members].map((p) => p.appearance)).size, 72);
for (const cell of h.members) {
assert.equal(cell.appearance.material, batch.appearance.material);
assert.equal(cell.appearance.material.uniforms.phaseTime, 9);
assert.equal(cell.appearance.material.uniforms.ghostAlpha, 0.25);
assert.equal(cell.compressVertices, false);
assert.equal(cell.releaseGeometryInstances, true);
}
batch.ready = false;
assert.equal(
h.owner.getDiagnostics().ready,
false,
'every cell must be ready',
);
batch.ready = true;
const instances = [...h.members].flatMap((p) => p.geometryInstances);
const baked = bakeWindStreamlines(field());
assert.equal(instances.length, baked.length);
const bySeed = new Map(
instances.map((i) => [i.attributes.windSeed.value[0], i]),
);
for (const path of baked) {
assert.deepEqual(
bySeed.get(path.seed).geometry.positions,
path.coordinates.map(([lon, lat]) =>
Cartesian3.fromDegrees(lon, lat, 12000),
),
);
}
for (const { sphere, points } of h.spheres) {
assert.deepEqual(sphere, BoundingSphere.fromPoints(points));
for (const point of points)
assert.ok(
Cartesian3.distance(point, sphere.center) <= sphere.radius + 1e-6,
);
}
for (const instance of instances) {
assert.ok(instance.geometry instanceof h.C.PolylineGeometry);
assert.equal(instance.geometry.width, 2.4);
assert.equal(instance.attributes.windSeed.componentsPerAttribute, 1);
assert.ok(Number.isInteger(instance.attributes.windSeed.value[0]));
}
});
test('replacement, clear and destroy dispose every owned cell and the shared material', () => {
const h = harness();
const assertDisposed = (batches) => {
assert.equal(batches.length, 72);
for (const batch of batches) {
assert.equal(batch.destroyed, true);
assert.equal(batch.appearance.material.destroyed, true);
}
};
h.owner.setField(field());
const first = [...h.members];
h.owner.setField(field());
assertDisposed(first);
const second = [...h.members];
h.owner.clear();
assertDisposed(second);
assert.equal(h.members.size, 0);
assert.equal(h.owner.getParticleCount(), 0);
assert.equal(h.owner.getDiagnostics().ready, false);
h.owner.setField(field());
const third = [...h.members];
h.owner.destroy();
assertDisposed(third);
assert.equal(h.members.size, 0);
assert.equal(h.owner.supported(), false);
assert.equal(h.owner.setField(field()), false);
});
test('unsupported or failed geometry cleanly requests the existing fallback', () => {
const missing = createWindGpuRendering({
cesium: {},
getViewer: () => ({ scene: {} }),
});
assert.equal(missing.setField(field()), false);
const h = harness();
h.C.PolylineGeometry = class {
constructor() {
throw new Error('test build failure');
}
};
assert.equal(h.owner.setField(field()), false);
assert.equal(h.members.size, 0);
assert.match(h.owner.getDiagnostics().error, /test build failure/);
});
test('narrow canvases use a lower bounded path budget', () => {
const h = harness({ width: 390 });
assert.equal(h.owner.setField(field()), true);
assert.ok(h.owner.getParticleCount() <= 1200);
assert.ok(h.owner.getParticleCount() > 1000);
});
test('cell visibility combines horizon, frustum and height with shared fade uniforms', () => {
const h = harness();
h.owner.setField(field());
const batches = [...h.members];
const camera = h.scene.camera;
h.visibility.horizon = (sphere) =>
h.spheres.findIndex((s) => s.sphere === sphere) % 2 === 0;
h.visibility.frustum = (sphere) =>
h.spheres.findIndex((s) => s.sphere === sphere) % 3 === 0
? Intersect.INTERSECTING
: Intersect.OUTSIDE;
assert.equal(h.owner.updateVisibility(camera), true);
assert.equal(h.owner.getDiagnostics().visibleCells, 12);
let vertices = 0;
for (let i = 0; i < batches.length; i++) {
assert.equal(batches[i].show, i % 6 === 0);
if (batches[i].show)
vertices += batches[i].geometryInstances.reduce(
(sum, p) => sum + p.geometry.positions.length * 4 - 4,
0,
);
}
assert.equal(h.owner.getDiagnostics().visibleVertexCount, vertices);
assert.equal(h.visibility.horizonTests, 72);
assert.equal(h.visibility.frustumTests, 36);
for (const [height, fade] of [
[60000, 1],
[37500, 0.5],
[15000, 0],
[1200, 0],
]) {
camera.positionCartographic.height = height;
assert.equal(h.owner.updateVisibility(camera), fade > 0);
assert.equal(h.owner.getDiagnostics().heightFade, fade);
for (const batch of batches) {
assert.equal(batch.appearance.material.uniforms.heightFade, fade);
assert.match(batch.appearance.material.source, /horizon \* heightFade/);
if (!fade) assert.equal(batch.show, false);
}
}
assert.equal(h.owner.getDiagnostics().visibleVertexCount, 0);
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
assert.equal(
h.visibility.horizonTests,
216,
'zero fade skips all sphere tests',
);
assert.equal(h.visibility.frustumTests, 108);
assert.equal(
h.occluderBuilds(),
1,
'occluder is reused across camera updates',
);
});
test('real Cesium sphere culling hides far hemisphere with globe hidden and rejects a sky-facing view', () => {
const h = harness();
h.C.Occluder = Occluder;
h.owner.setField(field());
const camera = h.scene.camera;
camera.frustum = new PerspectiveFrustum({
fov: Math.PI / 3,
aspectRatio: 1.5,
near: 1,
far: 5e7,
});
assert.equal(h.owner.updateVisibility(camera), true);
const facingEarth = h.owner.getDiagnostics();
assert.ok(facingEarth.visibleCells > 0 && facingEarth.visibleCells < 72);
for (let i = 0; i < h.spheres.length; i++) {
const sphere = h.spheres[i].sphere;
if (sphere.center.x < -5e6) assert.equal([...h.members][i].show, false);
}
camera.directionWC.x = 1;
assert.equal(h.owner.updateVisibility(camera), false);
assert.equal(h.owner.getDiagnostics().visibleCells, 0);
camera.positionWC = Cartesian3.fromDegrees(180, 0, 1e7);
assert.equal(
h.owner.updateVisibility(camera),
true,
'camera position updates the owned horizon test',
);
h.owner.destroy();
});
test('a failed later cell build releases already installed cells and shared material', () => {
const h = harness();
const NativePrimitive = h.C.Primitive;
const built = [];
h.C.Primitive = class extends NativePrimitive {
constructor(options) {
if (built.length === 2) throw new Error('later cell failed');
super(options);
built.push(this);
}
};
assert.equal(h.owner.setField(field()), false);
assert.equal(h.members.size, 0);
assert.equal(built.length, 2);
for (const batch of built) {
assert.equal(batch.destroyed, true);
assert.equal(batch.appearance.material.destroyed, true);
}
assert.match(h.owner.getDiagnostics().error, /later cell failed/);
});
+452
View File
@@ -0,0 +1,452 @@
import * as Cesium from 'cesium';
import { createWindRendering } from './rendering.js';
import { WIND_FIELDS } from './fields.js';
import {
formatWindReading,
windReadingResult,
windUnitChips,
} from './presentation.js';
import {
inspectWindAtCenter,
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
/** Format a forecast timestamp explicitly in UTC. */
export function formatWindValidTime(value) {
const ms = Date.parse(value);
return Number.isFinite(ms)
? `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`
: null;
}
/** Forecast issue time is source age; valid time is reported separately. */
export function windStats(manifest) {
const run = Date.parse(manifest?.cycle?.runIso);
return {
count: manifest?.grid ? manifest.grid.nx * manifest.grid.ny : 0,
lastUpdate: Number.isFinite(run) ? run : null,
error:
manifest?.reason || (manifest?.unavailable ? 'Wind unavailable' : null),
};
}
/** Construct one wind layer with an explicit source and owned animation. */
export function createWindLayer({
feed,
clock,
cesium = Cesium,
container,
createRendering = createWindRendering,
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Wind requires a snapshot source');
let viewer = null;
let request = null;
let enabled = false;
let rendering = null;
let imageryHost = null;
let manifest = null;
let error = null;
let loading = false;
let model = 'gfs';
let overlay = 'none';
let paused = false;
let units = 'km/h';
let reading = null;
let inspectionMarker = null;
let sampledPosition = null;
const hideInspection = () => {
reading = null;
sampledPosition = null;
inspectionMarker?.clear();
};
const sample = (position) => {
reading = inspectWindAtCenter(manifest, viewer, cesium, {
position,
units,
overlay,
model: model === 'ifs' ? 'ECMWF' : 'GFS',
validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
status: loading
? 'Loading forecast'
: error ||
manifest?.reason ||
(manifest?.stale ? 'Cached forecast · stale' : 'Model forecast'),
});
sampledPosition = reading.position || position || null;
inspectionMarker?.show(sampledPosition);
};
const requestedScalar = () =>
['temperature', 'pressure'].includes(overlay) ? overlay : 'none';
let generation = 0;
let rowControlsListener = null;
const notify = () => rowControlsListener?.();
const unsubscribeClock = clock?.subscribe(notify);
const layer = {
id: 'wind',
name: 'Wind',
icon: '🌬',
source: 'GFS / ECMWF IFS · FORECAST',
updateInterval: 3600_000,
init(nextViewer) {
viewer = nextViewer;
rendering = createRendering({
cesium,
container: container ?? nextViewer.container,
getViewer: () => viewer,
getHost: () =>
imageryHost?.() ?? {
collection: viewer.imageryLayers ?? viewer.scene?.imageryLayers,
kind: 'globe',
},
onStatusChange: notify,
});
rendering.attach();
rendering.setOptions?.({ overlay, paused });
const target = container ?? nextViewer.container;
inspectionMarker = createWindInspectionMarker({
container: target,
viewer,
cesium,
});
},
attachShellServices(services) {
imageryHost =
typeof services?.imageryHost === 'function'
? services.imageryHost
: null;
rendering?.rehome?.();
},
enable() {
enabled = true;
rendering?.start();
},
disable() {
enabled = false;
generation += 1;
request?.abort();
request = null;
loading = false;
// The renderer releases its field on disable. Retaining a manifest here
// would falsely satisfy an appearance-only reuse after the next enable.
manifest = null;
error = null;
hideInspection();
rendering?.stop();
rendering?.clear();
},
async update(nextViewer, { signal } = {}) {
if (!enabled) return false;
rendering?.rehome?.();
request?.abort();
const controller = new AbortController();
if (signal?.aborted) controller.abort(signal.reason);
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
request = controller;
generation += 1;
loading = true;
notify();
try {
signal?.throwIfAborted();
const snapshot = await feed.getSnapshot({
signal: controller.signal,
model,
overlay: requestedScalar(),
});
if (!enabled || controller.signal.aborted || request !== controller)
return false;
manifest = snapshot;
error = null;
if (sampledPosition) sample(sampledPosition);
if (snapshot.unavailable) {
rendering.stop();
rendering.clear();
} else {
rendering.setOptions?.({ overlay, paused });
rendering.setField(snapshot);
rendering.start();
}
return true;
} catch (cause) {
if (controller.signal.aborted || request !== controller) return false;
error = cause?.message || 'Wind source unavailable';
return true;
} finally {
signal?.removeEventListener('abort', abort);
if (request === controller) {
request = null;
loading = false;
notify();
}
}
},
setParams(params = {}) {
const previousScalar = requestedScalar();
const modelChanged =
['gfs', 'ifs'].includes(params.model) && params.model !== model;
const overlayChanged =
['none', 'speed', 'temperature', 'pressure'].includes(params.overlay) &&
params.overlay !== overlay;
if (typeof params.paused === 'boolean') paused = params.paused;
const unitsChanged =
Object.hasOwn(WIND_UNITS, params.units) && params.units !== units;
if (unitsChanged) units = params.units;
if (modelChanged) model = params.model;
if (overlayChanged) overlay = params.overlay;
rendering?.setOptions?.({ overlay, paused });
if (unitsChanged) reading = formatWindReading(reading, units);
if (params.inspect === false) hideInspection();
if (modelChanged) {
manifest = null;
error = null;
rendering?.stop();
rendering?.clear();
}
const needsField =
modelChanged ||
(overlayChanged &&
(!manifest?.u ||
manifest.unavailable ||
(requestedScalar() !== 'none' &&
manifest?.scalar?.kind !== overlay)));
const changesRequest =
modelChanged ||
(overlayChanged && previousScalar !== requestedScalar());
// None and Speed use the identical U/V request. Keep an active refresh
// alive while changing its presentation, including the first load.
if (changesRequest || (overlayChanged && needsField && !request)) {
request?.abort();
request = null;
const current = ++generation;
loading = enabled && needsField;
if (enabled && needsField)
queueMicrotask(() => {
if (enabled && generation === current) void layer.update(viewer);
});
else if (enabled && manifest && !manifest.unavailable)
rendering?.start();
}
if (overlayChanged && !needsField && sampledPosition)
sample(sampledPosition);
if (params.inspect === true && enabled) {
sample();
}
notify();
},
getParams() {
return { model, overlay, paused, units };
},
getRowControls() {
const historyStatus =
clock?.getState().mode === 'history'
? 'Forecast · does not follow history'
: null;
const valid = formatWindValidTime(manifest?.cycle?.validIso);
const run = formatWindValidTime(manifest?.cycle?.runIso);
const diagnostic = rendering?.getDiagnostics?.();
const motionPaused = paused || diagnostic?.reducedMotion;
const preparing =
enabled &&
diagnostic?.renderMode === 'gpu-streamlines' &&
diagnostic.gpu?.pathCount > 0 &&
!diagnostic.gpu?.ready;
const scalarMissing =
!loading &&
requestedScalar() !== 'none' &&
manifest &&
(!manifest.scalar || manifest.scalar.kind !== overlay);
const imageryError =
enabled && !loading ? diagnostic?.imageryError : null;
const label = {
none: 'Wind motion',
speed: 'Wind speed',
pressure: 'Sea-level pressure',
temperature: 'Air temperature · 2 m',
}[overlay];
const kind = ['temperature', 'pressure'].includes(overlay)
? overlay
: 'speed';
const spec = WIND_FIELDS[kind];
const legendUnit = kind === 'speed' ? units : spec.units;
const legend = spec.stops.map((color, index) => {
const value =
spec.min + ((spec.max - spec.min) * index) / (spec.stops.length - 1);
const display = Math.round(
kind === 'speed' ? value * WIND_UNITS[units] : value,
);
return {
color,
label: `${display}${index === spec.stops.length - 1 ? '+' : ''}`,
};
});
const speedLegendVisible =
!imageryError &&
(overlay === 'speed' ||
(overlay === 'none' && diagnostic?.renderMode === 'canvas-fallback'));
const controls = {
readout: true,
summary: {
label,
coverage: 'Global · 1° grid',
validTime: manifest?.cycle?.validIso,
issuedTime: manifest?.cycle?.runIso,
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
status: loading
? 'Loading forecast'
: preparing
? 'Preparing flow'
: error ||
manifest?.reason ||
imageryError ||
(scalarMissing ? 'Selected field unavailable' : null) ||
(manifest?.stale ? 'Cached forecast · stale' : null),
units: legendUnit,
},
chips: [
...['gfs', 'ifs'].map((value) => ({
id: `model-${value}`,
label: value === 'ifs' ? 'ECMWF' : 'GFS',
active: model === value,
params: { model: value },
title:
value === 'ifs'
? 'ECMWF IFS surface forecast'
: 'NOAA GFS surface forecast',
})),
{
id: 'motion',
label: diagnostic?.reducedMotion
? 'Reduced motion'
: paused
? 'Resume'
: 'Pause',
active: motionPaused,
disabled: Boolean(diagnostic?.reducedMotion),
params: { paused: !paused },
title:
'Pause visual flow; forecast time does not advance with animation',
},
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
id: `overlay-${value}`,
label: {
none: 'None',
speed: 'Speed',
pressure: 'Pressure',
temperature: 'Temperature',
}[value],
active: overlay === value,
params: { overlay: value },
title: {
none: 'Wind trails with no field shading',
speed: 'How hard the surface wind is blowing',
pressure: 'Sea-level air pressure: broad highs and lows',
temperature: 'Air temperature two meters above the surface',
}[value],
})),
...(speedLegendVisible ||
diagnostic?.renderMode === 'canvas-fallback' ||
Number.isFinite(reading?.speed)
? Object.keys(WIND_UNITS)
: []
).map((value) => ({
id: `units-${value}`,
label: value,
active: units === value,
params: { units: value },
title: 'Wind speed units',
})),
{
id: 'read-wind',
label: 'Read wind at map center',
params: { inspect: true },
disabled: loading || !manifest?.u || manifest?.unavailable,
title:
'Read the forecast at the center of the map without changing selection',
},
],
legend:
scalarMissing ||
imageryError ||
(overlay === 'none' && !speedLegendVisible)
? []
: legend,
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
infoTitle:
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
};
controls.summary.reading = reading;
controls.summary.result = reading ? windReadingResult(reading) : null;
const setting = (id, label, prefix) => ({
id,
label,
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
});
controls.summary.settings = [
setting('model', 'MODEL', 'model-'),
setting('field', 'FIELD', 'overlay-'),
...(controls.chips.some(({ id }) => id.startsWith('units-'))
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
: []),
setting('motion', 'MOTION', 'motion'),
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'read-wind',
);
return controls;
},
setRowControlsListener(listener) {
rowControlsListener = typeof listener === 'function' ? listener : null;
},
destroy() {
layer.disable();
unsubscribeClock?.();
rendering?.destroy();
rendering = null;
imageryHost = null;
viewer = null;
rowControlsListener = null;
inspectionMarker?.destroy();
inspectionMarker = null;
},
getStats() {
const diagnostic = rendering?.getDiagnostics?.();
const preparing =
enabled &&
diagnostic?.renderMode === 'gpu-streamlines' &&
diagnostic.gpu?.pathCount > 0 &&
!diagnostic.gpu?.ready;
const imageryError =
enabled && !loading
? rendering?.getDiagnostics?.()?.imageryError
: null;
return {
...windStats(manifest),
countLabel: 'Forecast',
loading: loading || Boolean(preparing),
model: model.toUpperCase(),
overlay,
paused,
stale: Boolean(manifest?.stale),
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
error:
error ||
windStats(manifest).error ||
(requestedScalar() !== 'none' ? manifest?.scalarError : null) ||
imageryError,
};
},
getDiagnostics() {
return rendering?.getDiagnostics?.() || {};
},
getParticleCount() {
return rendering?.getParticleCount() || 0;
},
};
return layer;
}
+414
View File
@@ -0,0 +1,414 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
const snapshot = (model) => ({
model,
grid: { nx: 2, ny: 2 },
cycle: { runIso: '2026-09-14T12:00:00Z', validIso: '2026-09-14T18:00:00Z' },
});
function harness(feed, diagnostics = {}) {
const calls = [];
const rendering = Object.fromEntries(
['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map((name) => [
name,
(...args) => calls.push([name, ...args]),
]),
);
rendering.getDiagnostics = () => diagnostics;
const layer = createWindLayer({ feed, createRendering: () => rendering });
layer.init({ container: {} });
layer.enable();
return { layer, calls };
}
test('forecast valid time and source age stay distinct', () => {
assert.equal(formatWindValidTime('invalid'), null);
assert.equal(
formatWindValidTime('2026-01-05T06:30:00Z'),
'2026-01-05 06:30 UTC',
);
assert.equal(
windStats(snapshot('gfs')).lastUpdate,
Date.parse('2026-09-14T12:00:00Z'),
);
});
test('switching models clears old data and ignores an abort-insensitive late source', async () => {
const pending = [];
const { layer, calls } = harness({
getSnapshot: (args) =>
new Promise((resolve) => pending.push({ ...args, resolve })),
});
const first = layer.update();
layer.setParams({ model: 'ifs' });
await Promise.resolve();
assert.equal(pending.length, 2);
assert.equal(pending[0].signal.aborted, true);
assert.equal(layer.getStats().count, 0);
assert.equal(layer.getStats().model, 'IFS');
pending[0].resolve(snapshot('gfs'));
await first;
assert.equal(calls.filter(([name]) => name === 'setField').length, 0);
pending[1].resolve(snapshot('ifs'));
await Promise.resolve();
await Promise.resolve();
assert.equal(calls.find(([name]) => name === 'setField')[1].model, 'ifs');
assert.match(
layer.getRowControls().info,
/IFS forecast[\s\S]*Valid: 2026-09-14 18:00 UTC[\s\S]*Issued: 2026-09-14 12:00 UTC/,
);
layer.destroy();
});
test('external and owned cancellation both cancel source work; queued switches stop on disable', async () => {
let signal;
const { layer } = harness({
getSnapshot: (args) => {
signal = args.signal;
return new Promise((resolve) =>
signal.addEventListener('abort', () => resolve(snapshot('gfs')), {
once: true,
}),
);
},
});
const external = new AbortController();
const update = layer.update(null, { signal: external.signal });
layer.disable();
assert.equal(signal.aborted, true);
assert.equal(external.signal.aborted, false);
assert.equal(await update, false);
layer.enable();
const other = layer.update(null, { signal: external.signal });
external.abort();
assert.equal(signal.aborted, true);
await other;
layer.setParams({ model: 'ifs' });
layer.disable();
await Promise.resolve();
assert.equal(layer.getStats().loading, false);
layer.destroy();
});
const complete = (model, extra = {}) => ({
...snapshot(model),
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
u: new Float32Array(4).fill(4),
v: new Float32Array(4).fill(3),
...extra,
});
test('local appearance and units reuse the loaded field; optional scalar requests remain separate', async () => {
const requests = [];
const { layer, calls } = harness({
getSnapshot: async (args) => {
requests.push(args);
return complete(
args.model,
args.overlay === 'pressure'
? {
scalar: {
kind: 'pressure',
units: 'hPa',
values: new Float32Array(4).fill(1013),
},
}
: {},
);
},
});
await layer.update();
layer.setParams({ overlay: 'none' });
layer.setParams({ overlay: 'speed', units: 'mph', paused: true });
await Promise.resolve();
assert.equal(requests.length, 1, 'speed/units/pause do not download weather');
assert.deepEqual(layer.getParams(), {
model: 'gfs',
overlay: 'speed',
units: 'mph',
paused: true,
});
layer.setParams({ overlay: 'pressure' });
await Promise.resolve();
await Promise.resolve();
assert.equal(requests.length, 2);
assert.equal(requests[1].overlay, 'pressure');
assert.equal(layer.getStats().overlay, 'pressure');
assert.ok(layer.getRowControls().info.includes('(hPa)'));
assert.equal(layer.getRowControls().legend.at(-1).label, '1050+');
layer.destroy();
});
test('local appearance changes preserve an in-flight first load', async () => {
const requests = [];
const { layer } = harness({
getSnapshot: (args) =>
new Promise((resolve) => requests.push({ ...args, resolve })),
});
const first = layer.update();
layer.setParams({ overlay: 'none' });
await Promise.resolve();
assert.equal(requests.length, 1);
assert.equal(requests[0].signal.aborted, false);
assert.equal(layer.getStats().loading, true);
requests[0].resolve(complete('gfs'));
await first;
assert.equal(layer.getStats().count, 4);
assert.equal(layer.getStats().loading, false);
layer.destroy();
});
test('re-enable and an appearance change cannot reuse a released renderer field', async () => {
const requests = [];
const { layer, calls } = harness({
getSnapshot: (args) =>
new Promise((resolve) => requests.push({ ...args, resolve })),
});
const first = layer.update();
requests[0].resolve(complete('gfs'));
await first;
layer.disable();
assert.equal(layer.getStats().count, 0);
layer.enable();
const second = layer.update();
layer.setParams({ overlay: 'none' });
await Promise.resolve();
assert.equal(requests.length, 2);
assert.equal(requests[1].signal.aborted, false);
assert.equal(layer.getStats().loading, true);
requests[1].resolve(complete('gfs'));
await second;
assert.equal(
calls.filter(([name]) => name === 'setField').length,
2,
'new field must reach the cleared renderer',
);
assert.equal(layer.getStats().count, 4);
assert.equal(layer.getStats().loading, false);
layer.destroy();
});
test('missing optional scalar retains valid wind and labels the field unavailable', async () => {
const { layer } = harness({
getSnapshot: async () =>
complete('gfs', {
overlay: 'pressure',
scalarError: 'Mean sea level pressure field unavailable',
}),
});
layer.setParams({ overlay: 'pressure' });
await Promise.resolve();
await Promise.resolve();
assert.equal(layer.getStats().count, 4);
assert.match(layer.getStats().error, /pressure field unavailable/);
assert.match(layer.getRowControls().info, /Selected field unavailable/);
assert.deepEqual(layer.getRowControls().legend, []);
layer.destroy();
});
test('a pending companion does not claim that it is already unavailable', async () => {
let resolve;
const { layer } = harness({
getSnapshot: (args) =>
args.overlay === 'none'
? Promise.resolve(complete('gfs'))
: new Promise((done) => {
resolve = done;
}),
});
await layer.update();
layer.setParams({ overlay: 'pressure' });
await Promise.resolve();
assert.equal(layer.getStats().loading, true);
assert.doesNotMatch(
layer.getRowControls().info,
/Selected field unavailable/,
);
resolve(
complete('gfs', {
scalarError: 'Mean sea level pressure field unavailable',
}),
);
await Promise.resolve();
await Promise.resolve();
assert.match(layer.getRowControls().info, /Selected field unavailable/);
layer.destroy();
});
test('an imagery failure appears in status and cannot retain a misleading field legend', async () => {
const diagnostics = { imageryError: null };
const { layer } = harness(
{ getSnapshot: async () => complete('gfs') },
diagnostics,
);
await layer.update();
diagnostics.imageryError = 'Globe field image unavailable';
assert.equal(layer.getStats().error, 'Globe field image unavailable');
assert.match(layer.getRowControls().info, /Globe field image unavailable/);
assert.deepEqual(layer.getRowControls().legend, []);
layer.destroy();
});
test('renderer readiness pushes fresh loading stats and controls to the displayed row', async () => {
let ready = false;
let statusChanged;
const rendering = {
attach() {},
start() {},
stop() {},
clear() {},
destroy() {},
setField() {},
getDiagnostics: () => ({
renderMode: 'gpu-streamlines',
gpu: { pathCount: 10, ready },
}),
};
const layer = createWindLayer({
feed: { getSnapshot: async () => snapshot('gfs') },
createRendering(options) {
statusChanged = options.onStatusChange;
return rendering;
},
});
layer.init({ container: {} });
layer.enable();
let displayed;
layer.setRowControlsListener(() => {
displayed = {
loading: layer.getStats().loading,
info: layer.getRowControls().info,
};
});
const update = layer.update();
const loadingInfo = layer.getRowControls().info;
assert.match(loadingInfo, /Valid: [^\n]+ · loading/);
await update;
assert.equal(displayed.loading, true);
assert.match(displayed.info, /Valid: [^\n]+ · preparing/);
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
ready = true;
statusChanged();
assert.equal(displayed.loading, false);
assert.doesNotMatch(displayed.info, / · preparing| · loading/);
assert.equal(displayed.info.split('\n').length, loadingInfo.split('\n').length);
layer.destroy();
});
test('weather summary describes the selected forecast and count remains numeric', async () => {
const { layer } = harness({ getSnapshot: async () => complete('gfs') });
await layer.update();
assert.equal(typeof layer.getStats().count, 'number');
assert.equal(layer.getStats().countLabel, 'Forecast');
const summary = layer.getRowControls().summary;
assert.equal(summary.label, 'Wind motion');
assert.equal(summary.units, 'km/h');
assert.match(summary.detail, /GFS forecast.*UTC/);
assert.equal((summary.detail.match(/UTC/g) || []).length, 1);
assert.equal(layer.getRowControls().chips.find(c => c.id === 'overlay-temperature').label, 'Temperature');
layer.destroy();
});
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
const nodes = [];
let listener;
let samples = 0;
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
getBoundingClientRect: () => ({ left: 0, top: 0 }),
};
const viewer = {
container,
camera: { positionWC: {}, pickEllipsoid: () => { samples++; return { longitude: 0, latitude: 0 }; } },
scene: {
mode: 3,
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 0, top: 0 }) },
cartesianToCanvasCoordinates: () => ({ x: 400, y: 300 }),
postRender: { addEventListener(fn) { listener = fn; return () => { listener = null; }; } },
requestRender() {},
},
};
const rendering = Object.fromEntries(['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map(name => [name, () => {}]));
const layer = createWindLayer({
feed: { getSnapshot: async ({ model }) => complete(model) },
cesium: {
Cartesian2: class {}, Ellipsoid: { WGS84: {} }, SceneMode: { SCENE3D: 3 },
Cartographic: { fromCartesian: point => point }, Math: { toDegrees: value => value },
EllipsoidalOccluder: class { isPointVisible() { return true; } },
},
createRendering: () => rendering,
});
layer.init(viewer);
layer.enable();
await layer.update();
layer.setParams({ inspect: true });
const captured = layer.getRowControls().summary.reading;
assert.equal(samples, 1);
const retained = nodes[0];
layer.setParams({ units: 'mph' });
assert.equal(nodes[0], retained);
const changed = layer.getRowControls().summary.reading;
assert.equal(changed.speed, captured.speed);
assert.equal(changed.coordinates, captured.coordinates);
assert.match(changed.wind, /mph/);
assert.equal(samples, 1, 'units do not resample');
assert.equal(layer.getRowControls().summary.result.id, 'reading');
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
assert.equal(nodes.length, 1);
assert.equal(typeof listener, 'function');
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
layer.setParams({ overlay: 'speed' });
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
layer.setParams({ model: 'ifs' });
await new Promise(resolve => setImmediate(resolve));
const resampled = layer.getRowControls().summary.reading;
assert.equal(resampled.coordinates, captured.coordinates);
assert.equal(resampled.model, 'ECMWF');
assert.equal(resampled.position, captured.position);
assert.equal(samples, 1, 'model and field changes never sample the moved camera');
assert.match(layer.getRowControls().summary.result.lines.find(({ id }) => id === 'meta').text, /ECMWF · valid/);
await layer.update();
assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates);
layer.setParams({ inspect: true });
assert.equal(layer.getRowControls().summary.reading.coordinates, '20.00°N · 70.00°E');
assert.equal(samples, 2, 'the next explicit read samples the new center');
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
layer.setParams({ inspect: true });
assert.equal(nodes.length, 1);
change();
assert.equal(nodes.length, 0);
assert.equal(layer.getRowControls().summary.reading, null);
assert.equal(layer.getRowControls().summary.result, null);
assert.equal(listener, null);
}
layer.destroy();
});
test('observed history labels wind as a forecast without changing its data or parameters', async () => {
const { createWeatherClock } = await import('../weather/clock.js');
const clock = createWeatherClock();
const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, clock });
let changes = 0;
layer.setRowControlsListener(() => changes++);
const params = layer.getParams();
await clock.setTarget('2026-09-14T12:00:00.000Z');
assert.equal(layer.getRowControls().summary.status, null, 'history does not mask source status');
assert.ok(changes > 0);
assert.match(layer.getRowControls().info, /Forecast · does not follow history/);
assert.deepEqual(layer.getParams(), params);
await clock.latest();
assert.equal(layer.getRowControls().summary.status, null);
layer.destroy();
const before = changes;
await clock.setTarget('2026-09-14T12:00:00.000Z');
assert.equal(changes, before);
clock.destroy();
});
test('wind unit chips appear only alongside a speed legend, including canvas trails', async () => {
let renderMode = 'gpu-streamlines'; let imageryError = null;
const layer = createWindLayer({ feed: { getSnapshot: async () => complete('gfs') }, createRendering: () => ({ attach() {}, start() {}, clear() {}, setField() {}, setOptions() {}, getDiagnostics: () => ({ renderMode, imageryError }), stop() {}, destroy() {} }) });
layer.init({ container: {} }); layer.enable(); await layer.update();
const unitChips = () => layer.getRowControls().chips.filter(({ id }) => id.startsWith('units-'));
assert.equal(layer.getRowControls().readout, true); assert.equal(layer.getRowControls().summary.coverage, 'Global · 1° grid');
assert.deepEqual(unitChips(), []); assert.deepEqual(layer.getRowControls().legend, []);
renderMode = 'canvas-fallback'; assert.equal(unitChips().length, 3); assert.ok(layer.getRowControls().legend.length);
renderMode = 'gpu-streamlines'; layer.setParams({ overlay: 'speed' }); assert.equal(unitChips().length, 3);
imageryError = 'Unavailable'; assert.deepEqual(unitChips(), []); imageryError = null;
for (const overlay of ['pressure', 'temperature', 'none']) { layer.setParams({ overlay }); assert.deepEqual(unitChips(), []); }
layer.destroy();
});
+167
View File
@@ -0,0 +1,167 @@
import { sampleWind, windSpeed } from './model.js';
import { sampleScalar } from './fields.js';
const DIRECTIONS = [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW',
];
export const WIND_UNITS = Object.freeze({
'km/h': 3.6,
'm/s': 1,
mph: 2.2369362921,
});
/** Meteorological bearing is where the wind comes FROM, not where it travels. */
export function windFrom(u, v) {
if (Math.hypot(u, v) < 0.2) return 'Calm';
const bearing = ((Math.atan2(-u, -v) * 180) / Math.PI + 360) % 360;
return DIRECTIONS[Math.round(bearing / 22.5) % 16];
}
export function formatWindSpeed(speed, units = 'km/h') {
const unit = Object.hasOwn(WIND_UNITS, units) ? units : 'km/h';
return `${(speed * WIND_UNITS[unit]).toFixed(1)} ${unit}`;
}
/** A deliberate snapshot at the map center; does not own pointer input. */
export function inspectWindAtCenter(
snapshot,
viewer,
cesium,
{ units, overlay, model, validTime, status, position } = {},
) {
const scene = viewer?.scene;
const canvas = scene?.canvas;
const ellipsoid = scene?.globe?.ellipsoid || cesium.Ellipsoid?.WGS84;
const point =
position ||
(canvas &&
viewer.camera?.pickEllipsoid?.(
new cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
ellipsoid,
));
if (!point || !snapshot?.u || !snapshot?.v)
return {
coordinates: 'No surface reading',
wind: 'Aim the map center at Earth',
model,
validTime,
status,
explanation:
'A location reading needs a loaded forecast and the Earth at the center of the view.',
};
const location = cesium.Cartographic.fromCartesian(point, ellipsoid);
const lon = cesium.Math.toDegrees(location.longitude);
const lat = cesium.Math.toDegrees(location.latitude);
const vector = sampleWind(
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
lon,
lat,
);
const speed = windSpeed(vector.u, vector.v);
const from = windFrom(vector.u, vector.v);
const scalar = ['temperature', 'pressure'].includes(overlay)
? sampleScalar(snapshot, lon, lat, overlay)
: null;
return {
position: point,
speed,
from,
units,
coordinates: `${Math.abs(lat).toFixed(2)}°${lat < 0 ? 'S' : 'N'} · ${Math.abs(lon).toFixed(2)}°${lon < 0 ? 'W' : 'E'}`,
wind: `${formatWindSpeed(speed, units)}${from === 'Calm' ? ' · calm' : ` from ${from}`}`,
scalarKind: overlay,
scalarLabel:
overlay === 'speed'
? 'Wind speed'
: overlay === 'temperature'
? 'Air temperature · 2 m'
: overlay === 'pressure'
? 'Sea-level pressure'
: null,
scalarValue:
overlay === 'speed'
? formatWindSpeed(speed, units)
: scalar == null
? null
: `${scalar.toFixed(1)} ${overlay === 'temperature' ? '°C' : 'hPa'}`,
model,
validTime,
status,
explanation:
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
};
}
/** One passive marker for the captured location; it never samples on camera movement. */
export function createWindInspectionMarker({ container, viewer, cesium }) {
let removeRender = null;
let marker = null;
const clear = () => {
removeRender?.();
removeRender = null;
marker?.remove();
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument?.createElement ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
marker = container.ownerDocument.createElement('div');
marker.className = 'gev-wind-inspection-marker';
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText =
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
container.appendChild(marker);
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
marker.hidden = !visible;
if (!visible) return;
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
windFrom,
formatWindSpeed,
inspectWindAtCenter,
} from './inspection.js';
test('wind directions describe the source of the flow and calm is not north', () => {
assert.equal(windFrom(10, 0), 'W');
assert.equal(windFrom(-10, 0), 'E');
assert.equal(windFrom(0, 10), 'S');
assert.equal(windFrom(0, -10), 'N');
assert.equal(windFrom(0, 0), 'Calm');
assert.equal(formatWindSpeed(10, 'km/h'), '36.0 km/h');
assert.equal(formatWindSpeed(10, 'mph'), '22.4 mph');
});
test('center inspection uses a fixed forecast reading without double-converting scalar units', () => {
const snapshot = {
grid: { nx: 2, ny: 2, lo1: 0, la1: 90, dx: 180, dy: 180 },
u: Float32Array.from([10, 10, 10, 10]),
v: new Float32Array(4),
scalar: {
kind: 'temperature',
units: '°C',
values: Float32Array.from([20, 20, 20, 20]),
},
};
const viewer = {
camera: { pickEllipsoid: () => ({ longitude: 0, latitude: 0 }) },
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
};
const cesium = {
Cartesian2: class {
constructor(x, y) {
this.x = x;
this.y = y;
}
},
Ellipsoid: { WGS84: {} },
Cartographic: { fromCartesian: (p) => p },
Math: { toDegrees: (x) => (x * 180) / Math.PI },
};
const result = inspectWindAtCenter(snapshot, viewer, cesium, {
overlay: 'temperature',
units: 'km/h',
model: 'GFS',
validTime: 'fixed',
status: 'forecast',
});
assert.equal(result.wind, '36.0 km/h from W');
assert.equal(result.scalarValue, '20.0 °C');
assert.equal(result.validTime, 'fixed');
assert.equal(result.coordinates, '0.00°N · 0.00°E');
viewer.camera.pickEllipsoid = () => null;
assert.equal(
inspectWindAtCenter(snapshot, viewer, cesium).coordinates,
'No surface reading',
);
});
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
const { createWindInspectionMarker } = await import('./inspection.js');
let listener = null;
let visible = true;
const projected = [];
const nodes = [];
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
getBoundingClientRect: () => ({ left: 5, top: 10 }),
};
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
requestRender() {},
},
};
const cesium = {
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
};
const owner = createWindInspectionMarker({ container, viewer, cesium });
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
assert.equal(nodes.length, 1);
assert.match(nodes[0].style.cssText, /pointer-events:none/);
assert.equal(nodes[0].style.left, '110px');
assert.equal(nodes[0].style.top, '220px');
viewer.camera.positionWC = { moved: true };
visible = false;
listener();
assert.equal(nodes[0].hidden, true);
assert.ok(projected.every((value) => value === point));
visible = true;
listener();
assert.equal(nodes[0].hidden, false);
owner.show({ x: 4 });
assert.equal(nodes.length, 1);
owner.clear();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
});
+89
View File
@@ -0,0 +1,89 @@
/** Normalize a longitude into the half-open world interval. */
export function normalizeLongitude(lon) {
return ((((lon + 180) % 360) + 360) % 360) - 180;
}
function interpolate(array, a, b, c, d, tx, ty) {
return (
(array[a] * (1 - tx) + array[b] * tx) * (1 - ty) +
(array[c] * (1 - tx) + array[d] * tx) * ty
);
}
/** Sample a north-to-south, seam-wrapped wind grid bilinearly. */
export function sampleWind(field, lon, lat, result = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
result.u = 0;
result.v = 0;
return result;
}
const { u, v, nx, ny, lo1, la1, dx, dy } = field;
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
const y = (la1 - lat) / dy;
const x0 = Math.floor(x);
const tx = x - x0;
const yClamped = Math.max(0, Math.min(ny - 1, y));
const y0 = Math.floor(yClamped);
const y1 = Math.min(ny - 1, y0 + 1);
const ty = yClamped - y0;
const left = ((x0 % nx) + nx) % nx;
const right = (left + 1) % nx;
const a = y0 * nx + left;
const b = y0 * nx + right;
const c = y1 * nx + left;
const d = y1 * nx + right;
result.u = interpolate(u, a, b, c, d, tx, ty);
result.v = interpolate(v, a, b, c, d, tx, ty);
return result;
}
/** Return metres represented by one longitude degree at a latitude. */
export function metersPerDegreeLon(lat) {
return 111320 * Math.cos((lat * Math.PI) / 180);
}
/** Calculate wind magnitude. */
export function windSpeed(u, v) {
return Math.hypot(u, v);
}
/** Map wind magnitude to a fixed CSS colour ramp (calm blue → strong red). */
export function windColor(speed, { max = 30 } = {}) {
const stops = [
'#1e3a8a',
'#2563eb',
'#22d3ee',
'#34d399',
'#fbbf24',
'#f97316',
'#ef4444',
];
const t = Math.max(0, Math.min(1, speed / max));
const position = t * (stops.length - 1);
const index = Math.min(stops.length - 2, Math.floor(position));
const fraction = position - index;
if (t === 1) return stops.at(-1);
const parse = (color) =>
color.match(/\w\w/g).map((part) => parseInt(part, 16));
const a = parse(stops[index]);
const b = parse(stops[index + 1]);
return `rgb(${a.map((value, i) => Math.round(value + (b[i] - value) * fraction)).join(', ')})`;
}
/** Advance a particle by a wind vector, mutating and returning it. */
export function advectParticle(
particle,
wind,
dtSeconds,
{ speedScale = 1 } = {},
) {
particle.lon = normalizeLongitude(
particle.lon +
(wind.u * dtSeconds * speedScale) / metersPerDegreeLon(particle.lat),
);
particle.lat = Math.max(
-89,
Math.min(89, particle.lat + (wind.v * dtSeconds * speedScale) / 111320),
);
return particle;
}
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { advectParticle, sampleWind, windColor } from './model.js';
const field = {
u: Float32Array.from([1, 2, 3, 4]),
v: Float32Array.from([5, 6, 7, 8]),
nx: 2,
ny: 2,
lo1: 0,
la1: 90,
dx: 180,
dy: 90,
};
test('sampleWind interpolates, wraps, clamps, and rejects non-finite input', () => {
assert.deepEqual(sampleWind(field, 0, 90), { u: 1, v: 5 });
assert.deepEqual(sampleWind(field, 90, 45), { u: 2.5, v: 6.5 });
assert.deepEqual(sampleWind(field, 359.99, 90), sampleWind(field, -0.01, 90));
assert.deepEqual(sampleWind(field, 0, 100), { u: 1, v: 5 });
assert.deepEqual(sampleWind(field, Infinity, 0), { u: 0, v: 0 });
});
test('advectParticle moves and constrains particles', () => {
const particle = { lon: 179, lat: 88 };
advectParticle(particle, { u: 10, v: 100 }, 3600);
assert.ok(particle.lon < 0);
assert.equal(particle.lat, 89);
});
test('windColor returns a monotonic ramp string', () => {
assert.equal(typeof windColor(0), 'string');
assert.notEqual(windColor(0), windColor(30));
});
+55
View File
@@ -0,0 +1,55 @@
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
/** Reformat a captured sample; changing units never samples the map again. */
export function formatWindReading(reading, units) {
if (!reading) return null;
return {
...reading,
units,
scalarValue:
reading.scalarKind === 'speed'
? formatWindSpeed(reading.speed, units)
: reading.scalarValue,
wind: Number.isFinite(reading.speed)
? `${formatWindSpeed(reading.speed, units)}${reading.from === 'Calm' ? ' · calm' : ` from ${reading.from}`}`
: reading.wind,
};
}
export function windUnitChips(units) {
return Object.keys(WIND_UNITS).map((value) => ({
id: `units-${value}`,
label: value,
active: units === value,
params: { units: value },
title: 'Wind speed units',
}));
}
/** Portable result block consumed by the WEATHER card. */
export function windReadingResult(reading) {
return {
id: 'reading',
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
lines: [
{
id: 'wind',
text: reading.wind,
},
{
id: 'meta',
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
},
...(reading.scalarValue
? [
{
id: 'scalar',
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
},
]
: []),
{ id: 'explanation', text: reading.explanation },
],
clear: { params: { inspect: false } },
};
}
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatWindReading, windReadingResult } from './presentation.js';
const sample = { speed: 5, from: 'SW', coordinates: '41.9°N · 87.6°W', model: 'NOAA GFS', validTime: '2026-09-21 12:00 UTC', scalarLabel: 'Air temperature · 2 m', scalarValue: '12 °C', explanation: 'Interpolated model forecast.' };
test('captured reading reformats without changing its sample and produces a portable result block', () => {
const reading = formatWindReading(sample, 'mph');
assert.equal(reading.wind, '11.2 mph from SW');
assert.equal(reading.speed, sample.speed);
assert.equal(reading.coordinates, sample.coordinates);
assert.equal(sample.units, undefined);
const section = windReadingResult(reading);
assert.equal(section.id, 'reading');
assert.equal(section.lines.find(({ id }) => id === 'scalar').text, 'Air temperature · 2 m · 12 °C');
assert.equal(section.lines.find(({ id }) => id === 'wind').text, '11.2 mph from SW');
assert.equal(section.lines.find(({ id }) => id === 'meta').text, 'NOAA GFS · valid 09-21 12:00 UTC');
assert.equal(section.label, 'WIND AT 41.9°N 87.6°W');
assert.deepEqual(section.clear.params, { inspect: false });
assert.equal(formatWindReading({ ...sample, from: 'Calm' }, 'm/s').wind, '5.0 m/s · calm');
assert.equal(formatWindReading(null, 'km/h'), null);
assert.equal(windReadingResult({ coordinates: 'No surface reading', wind: 'Unavailable' }).lines.some(({ id }) => id === 'scalar'), false);
});
+113
View File
@@ -0,0 +1,113 @@
// View-directed display lighting over existing terrain normals. This does not
// represent the Sun, change terrain geometry, or supply missing elevation data.
function reliefSource(terrainNormals) {
const normal = terrainNormals
? 'vec3 normal = normalize(materialInput.normalEC);'
: `vec3 worldPosition = (czm_inverseView * vec4(-materialInput.positionToEyeEC, 1.0)).xyz;
vec3 geodeticNormal = normalize(worldPosition * czm_ellipsoidInverseRadii * czm_ellipsoidInverseRadii);
vec3 normal = normalize(czm_viewRotation * geodeticNormal);`;
return `
czm_material czm_getMaterial(czm_materialInput materialInput)
{
czm_material material = czm_getDefaultMaterial(materialInput);
${normal}
vec3 displayLight = normalize(vec3(-0.45, 0.55, 0.8));
float shade = clamp((1.0 - dot(normal, displayLight)) * 0.32, 0.0, 0.28);
material.diffuse = vec3(0.0);
material.alpha = shade;
return material;
}
`;
}
/** Optional, exclusive globe-material owner; no animation or source acquisition. */
export function createWindRelief({ cesium, getViewer } = {}) {
let globe = null;
let scene = null;
let owned = null;
let previous = undefined;
let destroyed = false;
let reason = 'Not attached';
let mode = null;
const alive = (value) => value && !value.isDestroyed?.();
const active = () =>
Boolean(owned && alive(globe) && globe.material === owned);
function clear() {
const changed = active();
if (changed) globe.material = previous;
if (owned && !owned.isDestroyed?.()) owned.destroy?.();
if (changed && alive(scene)) scene.requestRender?.();
owned = null;
mode = null;
globe = null;
scene = null;
previous = undefined;
reason = destroyed ? 'Destroyed' : 'Not attached';
}
return {
attach() {
if (destroyed) return false;
const viewer = getViewer?.();
const nextScene = alive(viewer) ? viewer.scene : null;
const nextGlobe = nextScene?.globe;
const nextMode =
nextGlobe?.terrainProvider?.hasVertexNormals === true
? 'terrain relief'
: 'globe curvature';
if (active() && globe === nextGlobe && mode === nextMode) return true;
clear();
if (!alive(nextGlobe) || typeof cesium?.Material !== 'function') {
reason = 'Globe material unavailable';
return false;
}
if (nextGlobe.material != null) {
reason = 'Globe material already owned';
return false;
}
// Cesium excludes normal-dependent materials without terrain normals.
// The fallback uses existing position/ellipsoid uniforms: curvature only,
// never an invented terrain normal or an additional provider request.
try {
owned = new cesium.Material({
fabric: {
type:
nextMode === 'terrain relief'
? 'GEVWindTerrainRelief'
: 'GEVWindGlobeCurvature',
source: reliefSource(nextMode === 'terrain relief'),
},
translucent: true,
});
mode = nextMode;
globe = nextGlobe;
scene = nextScene;
previous = globe.material;
globe.material = owned;
scene.requestRender?.();
reason = null;
return true;
} catch {
clear();
reason = 'Relief material unavailable';
return false;
}
},
clear,
destroy() {
if (destroyed) return;
destroyed = true;
clear();
},
getDiagnostics() {
return {
active: active(),
reason: owned && !active() ? 'Globe material replaced' : reason,
lighting: 'View-directed display shading',
mode: active() ? mode : null,
maxOpacity: 0.28,
};
},
};
}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindRelief } from './relief.js';
function fixture({ material = undefined, normals = true } = {}) {
const materials = [];
class Material {
constructor(options) {
this.options = options;
this.destroyed = false;
materials.push(this);
}
isDestroyed() {
return this.destroyed;
}
destroy() {
assert.equal(this.destroyed, false);
this.destroyed = true;
}
}
let renders = 0;
const globe = { material, terrainProvider: { hasVertexNormals: normals } };
const scene = { globe, requestRender: () => renders++ };
const viewer = { scene };
const relief = createWindRelief({
cesium: { Material },
getViewer: () => viewer,
});
return { relief, globe, scene, viewer, materials, renders: () => renders };
}
test('relief acquires once and restores the exact prior empty material on clear', () => {
for (const material of [null, undefined]) {
const f = fixture({ material });
f.globe.material = material;
assert.equal(f.relief.attach(), true);
assert.equal(f.relief.attach(), true);
assert.equal(f.materials.length, 1);
assert.equal(f.globe.material, f.materials[0]);
assert.equal(f.relief.getDiagnostics().active, true);
f.relief.clear();
assert.equal(f.globe.material, material);
assert.equal(f.materials[0].destroyed, true);
assert.equal(f.renders(), 2);
f.relief.clear();
assert.equal(f.renders(), 2);
assert.equal(f.relief.attach(), true);
f.relief.destroy();
assert.equal(f.materials[1].destroyed, true);
assert.equal(f.relief.attach(), false);
}
});
test('pre-existing and replacement material owners are never overwritten', () => {
const other = { name: 'other-owner' };
const f = fixture({ material: other });
assert.equal(f.relief.attach(), false);
assert.equal(f.globe.material, other);
assert.equal(f.materials.length, 0);
f.globe.material = undefined;
assert.equal(f.relief.attach(), true);
f.globe.material = other;
assert.equal(f.relief.getDiagnostics().reason, 'Globe material replaced');
f.relief.clear();
assert.equal(f.globe.material, other);
assert.equal(f.materials[0].destroyed, true);
assert.equal(f.relief.attach(), false);
f.relief.destroy();
assert.equal(f.globe.material, other);
});
test('missing terrain normals uses curvature only and upgrades when normals exist', () => {
const f = fixture({ normals: false });
assert.equal(f.relief.attach(), true);
assert.equal(f.relief.getDiagnostics().mode, 'globe curvature');
const source = f.materials[0].options.fabric.source;
assert.doesNotMatch(source, /normalEC|slope/);
assert.match(source, /materialInput.positionToEyeEC/);
assert.match(source, /czm_ellipsoidInverseRadii/);
f.globe.terrainProvider.hasVertexNormals = true;
assert.equal(f.relief.attach(), true);
assert.equal(f.materials[0].destroyed, true);
assert.equal(f.relief.getDiagnostics().mode, 'terrain relief');
assert.match(f.materials[1].options.fabric.source, /materialInput.normalEC/);
f.relief.destroy();
assert.equal(f.globe.material, undefined);
});
test('moving to a new viewer releases the old globe and owns only the new one', () => {
const f = fixture();
f.relief.attach();
const old = f.globe;
const next = { material: null, terrainProvider: { hasVertexNormals: true } };
f.viewer.scene = { globe: next, requestRender() {} };
assert.equal(f.relief.attach(), true);
assert.equal(old.material, undefined);
assert.equal(f.materials[0].destroyed, true);
assert.equal(next.material, f.materials[1]);
f.relief.destroy();
assert.equal(next.material, null);
});
+801
View File
@@ -0,0 +1,801 @@
import {
createShellSurface,
WEATHER_SHELL_HEIGHTS,
} from '../weather/shellRendering.js';
import { createWindRelief } from './relief.js';
import { orderWeatherImagery } from '../weather/imageryOrder.js';
import { NO_IMAGERY_HOST } from '../weather/imageryHost.js';
import { createWindGpuRendering } from './gpuRendering.js';
import { advectParticle, sampleWind } from './model.js';
import {
createFieldRaster,
trailEraseAlpha,
windTrailColor,
} from './fields.js';
const WIND_FIELD_FADE_HIGH_METERS = 1_200_000;
const WIND_FIELD_FADE_LOW_METERS = 200_000;
const WIND_FIELD_LOG_RANGE = Math.log(
WIND_FIELD_FADE_HIGH_METERS / WIND_FIELD_FADE_LOW_METERS,
);
// Snapshots own immutable decoded arrays. Compare their contents, not grid URLs
// (which include the scalar overlay) or only cycle IDs (which can be revised).
// This bounded scan runs on acquisition, never in the animation loop.
function sameWind(previous, next, previousField, nextField) {
if (!previous || !next || !previousField || !nextField) return false;
for (const key of ['model', 'level', 'units'])
if (previous[key] !== next[key]) return false;
for (const key of ['runIso', 'validIso', 'forecastHour', 'date', 'hour'])
if (previous.cycle?.[key] !== next.cycle?.[key]) return false;
for (const key of ['nx', 'ny', 'lo1', 'la1', 'dx', 'dy'])
if (previousField[key] !== nextField[key]) return false;
for (const key of ['u', 'v']) {
const before = previousField[key];
const after = nextField[key];
if (!before || !after || before.length !== after.length) return false;
if (before === after) continue;
for (let i = 0; i < before.length; i++)
if (before[i] !== after[i]) return false;
}
return true;
}
/** Globe-projected surface flow plus one owned, static colour field: globe
* imagery on the globe host, a raised shell on the 3D Tiles host. */
export function createWindRendering({
cesium,
container,
getViewer,
getHost = () => ({
collection:
getViewer?.()?.imageryLayers ?? getViewer?.()?.scene?.imageryLayers,
kind: 'globe',
}),
eventTarget = globalThis.window,
createGpuRendering = createWindGpuRendering,
onStatusChange,
} = {}) {
let canvas = null;
let context = null;
let snapshot = null;
let field = null;
let particles = [];
let frame = null;
let running = false;
let lastTime = null;
let cssWidth = 1;
let cssHeight = 1;
let warned = false;
let overlay = 'none';
let paused = false;
let reducedMotion = false;
let imagery = null;
let shell = null;
let imageryCollection = null;
let imageryKind = null;
let imageryError = null;
let imageryErrorRemove = null;
let media = null;
let removers = [];
let bounds = null;
let cameraChanged = true;
let painted = 0;
const gpu = createGpuRendering({ cesium, getViewer });
let gpuActive = false;
let gpuVisible = false;
let reportedGpuReady = null;
let gpuNarrow = false;
let flowTime = 0;
const relief = createWindRelief({ cesium, getViewer });
const signature = Array(8).fill(null);
const wind = { u: 0, v: 0 };
const before = { lon: 0, lat: 0 };
const world =
typeof cesium.Cartesian3 === 'function' ? new cesium.Cartesian3() : {};
const screenA =
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
const screenB =
typeof cesium.Cartesian2 === 'function' ? new cesium.Cartesian2() : {};
const degree = Math.PI / 180;
const hidden = () => globalThis.document?.hidden === true;
const still = () => paused || reducedMotion;
const budget = () =>
Math.max(
200,
Math.min(
cssWidth < 600 ? 1000 : 3000,
Math.floor((cssWidth * cssHeight) / 420),
),
);
const viewerReady = () => {
const viewer = getViewer?.();
return viewer && !viewer.isDestroyed?.() ? viewer : null;
};
function cancel() {
if (frame !== null) globalThis.cancelAnimationFrame(frame);
frame = null;
}
function schedule() {
if (
running &&
field &&
canvas &&
!hidden() &&
!still() &&
(!gpuActive || gpuVisible) &&
frame === null
)
frame = globalThis.requestAnimationFrame(draw);
}
function clearPixels() {
context?.clearRect(0, 0, cssWidth, cssHeight);
}
function resize(viewer) {
const ratio = Math.min(2, globalThis.devicePixelRatio || 1);
cssWidth =
viewer?.scene?.canvas?.clientWidth || container?.clientWidth || 1;
cssHeight =
viewer?.scene?.canvas?.clientHeight || container?.clientHeight || 1;
const width = Math.max(1, Math.floor(cssWidth * ratio));
const height = Math.max(1, Math.floor(cssHeight * ratio));
// Assigning even unchanged dimensions erases a real canvas.
if (canvas.width === width && canvas.height === height) return false;
canvas.width = width;
canvas.height = height;
canvas.style.width = `${cssWidth}px`;
canvas.style.height = `${cssHeight}px`;
context.setTransform?.(ratio, 0, 0, ratio, 0, 0);
return true;
}
function checkCamera(scene) {
const camera = scene.camera;
const position = camera?.positionWC;
const values = [
position?.x,
position?.y,
position?.z,
camera?.heading,
camera?.pitch,
camera?.roll,
cssWidth,
cssHeight,
];
let changed = false;
for (let i = 0; i < values.length; i++) {
if (values[i] !== signature[i]) changed = true;
signature[i] = values[i];
}
return changed;
}
function updateBounds(scene) {
bounds = null;
if (!scene.camera?.computeViewRectangle || !cesium.Ellipsoid) return;
try {
const rect = scene.camera.computeViewRectangle(cesium.Ellipsoid.WGS84);
if (!rect) return;
const west = rect.west / degree;
let east = rect.east / degree;
const south = Math.max(-89, rect.south / degree);
const north = Math.min(89, rect.north / degree);
if (east < west) east += 360;
if (
[west, east, south, north].every(Number.isFinite) &&
east > west &&
north > south
)
bounds = {
west,
east,
sinSouth: Math.sin(south * degree),
sinNorth: Math.sin(north * degree),
};
} catch {
/* A sky-facing camera can have no earth rectangle. */
}
}
function makeOccluder(scene) {
if (
!cesium.EllipsoidalOccluder ||
!cesium.Ellipsoid ||
!scene.camera?.positionWC
)
return null;
return new cesium.EllipsoidalOccluder(
cesium.Ellipsoid.WGS84,
scene.camera.positionWC,
);
}
function project(scene, occluder, lon, lat, result) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
const point = cesium.Cartesian3.fromDegrees(
lon,
lat,
0,
cesium.Ellipsoid?.WGS84,
world,
);
if (occluder && !occluder.isPointVisible(point)) return null;
const projected = cesium.SceneTransforms.worldToWindowCoordinates(
scene,
point,
result,
);
if (
!projected ||
!Number.isFinite(projected.x) ||
!Number.isFinite(projected.y)
)
return null;
// A perspective surface normal dims the limb instead of accumulating a bright rim.
const camera = scene.camera?.positionWC;
const dx = camera?.x - point.x;
const dy = camera?.y - point.y;
const dz = camera?.z - point.z;
const denominator =
Math.hypot(point.x, point.y, point.z) * Math.hypot(dx, dy, dz);
const cosine =
denominator > 0
? (point.x * dx + point.y * dy + point.z * dz) / denominator
: 1;
projected.visibility = Number.isFinite(cosine)
? Math.max(0, Math.min(1, cosine / 0.3))
: 1;
return projected;
}
const inView = (point) =>
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= cssWidth &&
point.y <= cssHeight;
function spawn(particle, scene, occluder) {
for (let attempt = 0; attempt < 6; attempt++) {
const west = bounds?.west ?? -180;
const east = bounds?.east ?? 180;
const low = bounds?.sinSouth ?? -0.9998;
const high = bounds?.sinNorth ?? 0.9998;
particle.lon = ((west + Math.random() * (east - west) + 540) % 360) - 180;
particle.lat = Math.asin(low + Math.random() * (high - low)) / degree;
particle.age = 1 + Math.random() * 7;
particle.life = 8 + Math.random() * 4;
if (inView(project(scene, occluder, particle.lon, particle.lat, screenA)))
return;
}
}
function seed(scene, occluder) {
const count = budget();
if (particles.length > count) particles.length = count;
while (particles.length < count)
particles.push({ lon: 0, lat: 0, age: 0, life: 10 });
updateBounds(scene);
for (const particle of particles) spawn(particle, scene, occluder);
}
function removeImagery() {
imageryErrorRemove?.();
imageryErrorRemove = null;
if (imagery && imageryCollection && !imageryCollection.isDestroyed?.())
imageryCollection.remove(imagery, true);
else if (imagery && !imagery.isDestroyed?.()) imagery.destroy?.();
imagery = null;
shell?.destroy();
shell = null;
imageryCollection = null;
imageryKind = null;
}
function updateImageryFade(camera, settled = false) {
// The tileset shell keeps the quantised move-end fade; globe imagery keeps
// its smooth per-frame fade.
if (
(!imagery && !shell) ||
!camera?.positionCartographic ||
(imageryKind === 'tileset' && !settled)
)
return;
const height = camera.positionCartographic.height;
const fade = !gpuActive
? 1
: height <= WIND_FIELD_FADE_LOW_METERS
? 0
: height >= WIND_FIELD_FADE_HIGH_METERS
? 1
: Math.log(height / WIND_FIELD_FADE_LOW_METERS) /
WIND_FIELD_LOG_RANGE;
const baseAlpha = overlay === 'temperature' ? 1 : 0.85;
const smoothAlpha = baseAlpha * fade;
const alpha =
gpuActive && imageryKind === 'tileset'
? Math.round(smoothAlpha * 10) / 10
: smoothAlpha;
if (shell) {
shell.setAlpha(alpha);
shell.setShow(alpha > 0 && getHost().kind !== 'none');
return;
}
if (Math.abs(imagery.alpha - alpha) > 0.005) imagery.alpha = alpha;
const show = alpha > 0;
if (imagery.show !== show) imagery.show = show;
}
function installImagery() {
removeImagery();
imageryError = null;
const viewer = viewerReady();
if (!snapshot || overlay === 'none' || !viewer) return;
const { collection, kind } = getHost();
if (kind === 'none') {
imageryError = NO_IMAGERY_HOST;
return;
}
const raster = createFieldRaster(snapshot, overlay);
if (!raster) {
imageryError = `${overlay} field unavailable`;
return;
}
if (
kind === 'tileset'
? !cesium.Primitive
: !collection || !cesium.SingleTileImageryProvider
) {
imageryError = 'Globe imagery unavailable';
return;
}
try {
const texture = document.createElement('canvas');
texture.width = raster.width;
texture.height = raster.height;
const ctx = texture.getContext('2d');
const pixels = ctx.createImageData(raster.width, raster.height);
pixels.data.set(raster.rgba);
ctx.putImageData(pixels, 0, 0);
if (kind === 'tileset') {
shell = createShellSurface({
viewer,
cesium,
rectangle: cesium.Rectangle.MAX_VALUE,
height: WEATHER_SHELL_HEIGHTS.wind,
});
shell.setImage(texture);
imageryKind = kind;
shell.setAlpha(overlay === 'temperature' ? 1 : 0.85);
updateImageryFade(viewer.scene.camera, true);
viewer.scene?.requestRender?.();
return;
}
const provider = new cesium.SingleTileImageryProvider({
url: texture.toDataURL('image/png'),
tileWidth: raster.width,
tileHeight: raster.height,
rectangle: cesium.Rectangle.MAX_VALUE,
});
imagery = collection.addImageryProvider(provider);
orderWeatherImagery(collection, imagery, 0);
imageryCollection = collection;
imageryKind = kind;
// Temperature colors carry quantitative meaning; double transparency
// blends orange heat into blue ocean and obscures useful gradients.
imagery.alpha = overlay === 'temperature' ? 1 : 0.85;
if (gpuActive) updateImageryFade(viewer.scene.camera, true);
imageryErrorRemove = provider.errorEvent?.addEventListener(() => {
imageryError = 'Globe field image unavailable';
});
viewer.scene?.requestRender?.();
} catch {
removeImagery();
imageryError = 'Globe field image unavailable';
}
}
function rehome() {
if (!snapshot || overlay === 'none') return;
const { collection, kind } = getHost();
const wasHidden = imageryError === NO_IMAGERY_HOST;
if ((imagery || shell) && kind !== 'none' && kind !== imageryKind) {
installImagery();
onStatusChange?.();
return;
}
// A host without imagery hides the retained shell until a host returns.
if (shell && kind === 'none') shell.setShow(false);
if (imagery && collection !== imageryCollection) {
imageryCollection?.remove(imagery, false);
imageryCollection = collection;
if (collection) {
collection.add(imagery);
orderWeatherImagery(collection, imagery, 0);
}
viewerReady()?.scene?.requestRender?.();
}
if ((gpuActive || kind === 'tileset') && kind !== 'none')
updateImageryFade(viewerReady()?.scene?.camera, true);
if (kind === 'none') imageryError = NO_IMAGERY_HOST;
else if (wasHidden) {
imageryError = null;
if (!imagery && !shell) installImagery();
}
if (wasHidden !== (imageryError === NO_IMAGERY_HOST)) onStatusChange?.();
}
/** No idle animation for pause/reduced motion; scene events repaint only changed views. */
function viewChanged() {
if (gpuActive) {
const viewer = viewerReady();
if (viewer) updateImageryFade(viewer.scene.camera);
const narrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
let visibilityUpdated = false;
if (running && field && !hidden() && narrow !== gpuNarrow) {
gpuNarrow = narrow;
gpuActive = gpu.setField(field);
if (!gpuActive && viewer) {
resize(viewer);
seed(viewer.scene, makeOccluder(viewer.scene));
}
motionChanged();
visibilityUpdated = true;
}
if (gpuActive && viewer && !visibilityUpdated) {
gpuVisible = gpu.updateVisibility(viewer.scene.camera);
if (gpuVisible) schedule();
else {
cancel();
lastTime = null;
}
}
const ready = gpuActive ? gpu.getDiagnostics().ready : null;
if (ready !== reportedGpuReady) {
reportedGpuReady = ready;
onStatusChange?.();
}
return; // Cesium projects persistent curves; only a responsive budget change rebuilds them.
}
if (!running || !field || hidden() || !canvas) return;
const viewer = viewerReady();
if (!viewer) return;
const resized = resize(viewer);
if (resized || checkCamera(viewer.scene)) {
cameraChanged = true;
if (still()) paint(viewer, 0, true);
else schedule();
}
}
function motionChanged() {
reducedMotion = media?.matches === true;
cancel();
lastTime = null;
cameraChanged = true;
gpu.setOptions({ overlay, paused: still() });
if (!running || !field || hidden()) return;
const viewer = viewerReady();
if (gpuActive) {
if (viewer) updateImageryFade(viewer.scene.camera);
gpuVisible = viewer ? gpu.updateVisibility(viewer.scene.camera) : false;
if (gpuVisible) {
gpu.tick(flowTime);
viewer.scene.requestRender();
schedule();
}
return;
}
if (still() && viewer) paint(viewer, 0, true);
else schedule();
}
function listen(target, event, callback) {
if (!target?.addEventListener) return;
target.addEventListener(event, callback);
removers.push(() => target.removeEventListener(event, callback));
}
function cameraSettled() {
if (gpuActive || imageryKind === 'tileset')
updateImageryFade(viewerReady()?.scene?.camera, true);
cameraMoved();
}
function cameraMoved() {
viewChanged();
// A parked request-render scene may never run preRender until we wake it.
if (gpuActive && running && field && !hidden())
viewerReady()?.scene?.requestRender?.();
}
function listenScene(event, callback) {
if (!event?.addEventListener) return;
const remove = event.addEventListener(callback);
removers.push(
typeof remove === 'function'
? remove
: () => event.removeEventListener(callback),
);
}
function attachListeners() {
if (removers.length) return;
media = globalThis.matchMedia?.('(prefers-reduced-motion: reduce)') ?? null;
reducedMotion = media?.matches === true;
listen(media, 'change', motionChanged);
listen(globalThis.document, 'visibilitychange', motionChanged);
listen(globalThis, 'resize', cameraMoved);
listen(eventTarget, 'gev:map-stack-changed', rehome);
const viewer = viewerReady();
const camera = viewer?.scene?.camera;
listenScene(viewer?.scene?.preRender, viewChanged);
listenScene(camera?.moveEnd, cameraSettled);
}
function detachListeners() {
for (const remove of removers) remove();
removers = [];
media = null;
}
function paint(viewer, dt, staticFrame, fadeSeconds = dt) {
const scene = viewer.scene;
const resized = resize(viewer);
const changed = checkCamera(scene) || cameraChanged || resized;
const occluder = makeOccluder(scene);
if (changed) {
clearPixels();
updateBounds(scene);
cameraChanged = false;
}
if (particles.length !== budget()) seed(scene, occluder);
if (staticFrame) clearPixels();
context.globalAlpha = 1;
context.globalCompositeOperation = 'destination-out';
context.fillStyle = `rgba(0,0,0,${trailEraseAlpha(fadeSeconds)})`;
context.fillRect(0, 0, cssWidth, cssHeight);
context.globalCompositeOperation = 'source-over';
context.lineCap = 'round';
const cameraHeight = scene.camera?.positionCartographic?.height ?? 1e6;
const speedScale = Math.max(0.002, Math.min(4000, cameraHeight / 400));
const advection = { speedScale };
// Stroke length is a display sample of the local field, not frame travel.
// At low FPS capped advection still moves the head by dt, but a readable
// world-anchored tail survives without extra projections or screen stretching.
const strokeSeconds = staticFrame ? 3 : 2;
painted = 0;
for (const particle of particles) {
sampleWind(field, particle.lon, particle.lat, wind);
if (!staticFrame) {
advectParticle(particle, wind, dt, advection);
particle.age += dt;
}
let point = project(scene, occluder, particle.lon, particle.lat, screenB);
if (changed && !inView(point)) {
// Refill newly exposed regions while retaining world anchors still in view.
spawn(particle, scene, occluder);
sampleWind(field, particle.lon, particle.lat, wind);
point = project(scene, occluder, particle.lon, particle.lat, screenB);
}
if (particle.age > particle.life || !inView(point)) {
spawn(particle, scene, occluder);
continue;
}
before.lon = particle.lon;
before.lat = particle.lat;
advectParticle(before, wind, -strokeSeconds, advection);
const previous = project(
scene,
occluder,
before.lon,
before.lat,
screenA,
);
if (!inView(previous)) continue;
const startX = previous.x;
const startY = previous.y;
const visibility = previous.visibility;
const distance = Math.hypot(point.x - startX, point.y - startY);
// Perspective/frustum jumps and antimeridian discontinuities never become strokes.
if (
Math.abs(before.lon - particle.lon) > 180 ||
distance > Math.min(80, cssWidth * 0.08)
)
continue;
const alpha =
Math.min(visibility, point.visibility) *
Math.min(1, particle.age / 0.8, (particle.life - particle.age) / 1.2);
if (alpha <= 0) continue;
const speed = Math.hypot(wind.u, wind.v);
context.globalAlpha = alpha * 0.55;
context.lineWidth = 1.0;
context.strokeStyle =
overlay === 'none' ? windTrailColor(speed) : '#9ddce4';
context.beginPath();
context.moveTo(startX, startY);
context.lineTo(point.x, point.y);
context.stroke();
if (distance > 0.6) {
context.globalAlpha = alpha * 0.9;
context.strokeStyle = '#d6f4f5';
context.lineWidth = 1.35;
context.beginPath();
context.moveTo(
startX + (point.x - startX) * 0.72,
startY + (point.y - startY) * 0.72,
);
context.lineTo(point.x, point.y);
context.stroke();
}
if (staticFrame) {
// Preserve direction at rest with a small arrowhead, without moving the field.
if (distance > 2) {
const ux = (point.x - startX) / distance;
const uy = (point.y - startY) / distance;
context.beginPath();
context.moveTo(point.x - ux * 3 - uy * 2, point.y - uy * 3 + ux * 2);
context.lineTo(point.x, point.y);
context.lineTo(point.x - ux * 3 + uy * 2, point.y - uy * 3 - ux * 2);
context.stroke();
}
}
painted++;
}
context.globalAlpha = 1;
}
function draw(time) {
frame = null;
if (!running || !field || hidden() || still()) return;
if (gpuActive && !gpuVisible) {
lastTime = null;
return;
}
const viewer = viewerReady();
if (!viewer) return;
try {
const resized = !gpuActive && resize(viewer);
if (resized) cameraChanged = true;
const elapsed =
lastTime === null ? 1 / 60 : Math.max(0, (time - lastTime) / 1000);
// Limit full particle paints to 30 Hz while preserving elapsed-time motion/fading.
if (lastTime === null || elapsed >= 0.032 || cameraChanged) {
if (gpuActive) {
flowTime += elapsed;
gpu.tick(flowTime);
viewer.scene.requestRender();
cameraChanged = false;
} else paint(viewer, Math.min(0.1, elapsed), false, elapsed);
lastTime = time;
}
} catch (error) {
if (!warned) {
console.warn('[Data:Wind] frame failed:', error?.message || error);
warned = true;
}
}
schedule();
}
return {
rehome,
attach() {
if (canvas) return;
canvas = document.createElement('canvas');
canvas.dataset.gevWind = '1';
Object.assign(canvas.style, {
position: 'absolute',
inset: '0',
pointerEvents: 'none',
zIndex: '1',
});
canvas.setAttribute?.('aria-hidden', 'true');
context = canvas.getContext('2d');
container.appendChild(canvas);
},
setField(next) {
const nextField = next?.grid
? { ...next.grid, u: next.u, v: next.v }
: next;
const viewer = viewerReady();
const nextNarrow = (viewer?.scene?.canvas?.clientWidth || 800) < 700;
const reuseGeometry =
gpuActive &&
gpuNarrow === nextNarrow &&
gpu.supported() &&
sameWind(snapshot, next, field, nextField);
snapshot = next;
field = nextField;
cameraChanged = true;
lastTime = null;
cancel();
if (canvas && field) {
gpuNarrow = nextNarrow;
if (!reuseGeometry) {
gpuActive = gpu.supported() && gpu.setField(field);
reportedGpuReady = gpuActive ? gpu.getDiagnostics().ready : null;
flowTime = 0;
}
clearPixels();
particles = [];
if (viewer && !gpuActive) {
resize(viewer);
seed(viewer.scene, makeOccluder(viewer.scene));
}
installImagery();
relief.attach();
motionChanged();
}
},
setOptions(options = {}) {
const nextOverlay = ['none', 'speed', 'temperature', 'pressure'].includes(
options.overlay,
)
? options.overlay
: overlay;
const newPaused =
typeof options.paused === 'boolean' ? options.paused : paused;
const changedOverlay = nextOverlay !== overlay;
const changedPause = newPaused !== paused;
overlay = nextOverlay;
paused = newPaused;
if (changedOverlay) {
installImagery();
clearPixels();
}
if (changedOverlay || changedPause) motionChanged();
},
start() {
if (running || !canvas) return;
running = true;
attachListeners();
rehome();
motionChanged();
},
stop() {
running = false;
cancel();
lastTime = null;
detachListeners();
gpu.setOptions({ overlay, paused: true });
},
clear() {
cancel();
snapshot = null;
field = null;
particles = [];
painted = 0;
gpu.clear();
relief.clear();
gpuActive = false;
gpuVisible = false;
reportedGpuReady = null;
flowTime = 0;
removeImagery();
imageryError = null;
clearPixels();
},
destroy() {
this.stop();
this.clear();
gpu.destroy();
relief.destroy();
canvas?.remove();
canvas = null;
context = null;
},
getParticleCount() {
return gpuActive ? gpu.getParticleCount() : particles.length;
},
getDiagnostics() {
return {
overlay,
paused,
reducedMotion,
hidden: hidden(),
particleCount: gpuActive ? gpu.getParticleCount() : particles.length,
renderMode: gpuActive ? 'gpu-streamlines' : 'canvas-fallback',
gpu: gpu.getDiagnostics(),
relief: relief.getDiagnostics(),
painted,
framePending: frame !== null,
imageryActive: imagery !== null || shell !== null,
host: shell ? 'shell' : imagery ? 'globe' : null,
shell: shell?.getDiagnostics() ?? null,
imageryError,
};
},
};
}
File diff suppressed because it is too large. Load diff
+105
View File
@@ -0,0 +1,105 @@
import { readWindBody } from '../../sources/windBody.js';
/** Create a bounded, cancellable source for the same-origin wind provider. */
export function createWindSource({
fetchImpl = (...args) => globalThis.fetch(...args),
timeoutMs = 45_000,
} = {}) {
return {
async getSnapshot({ signal, model = 'gfs', overlay = 'none' } = {}) {
if (!['gfs', 'ifs'].includes(model))
throw new Error('Unknown wind model');
if (!['none', 'temperature', 'pressure'].includes(overlay))
throw new Error('Unknown weather overlay');
const query = `model=${model}${overlay === 'none' ? '' : `&overlay=${overlay}`}`;
const controller = new AbortController();
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Wind request timed out')),
timeoutMs,
);
const active = controller.signal;
try {
signal?.throwIfAborted();
const response = await fetchImpl(`/api/wind/manifest?${query}`, {
signal: active,
cache: 'no-store',
redirect: 'error',
});
if (!response.ok) throw new Error(`Wind HTTP ${response.status}`);
const manifest = JSON.parse(
new TextDecoder().decode(
await readWindBody(response, 16_384, active),
),
);
if (manifest?.unavailable) return manifest;
const grid = manifest?.grid;
const scalar = manifest?.scalar;
const scalarError = manifest?.scalarError;
const optionalUnavailable =
overlay !== 'none' &&
scalar === undefined &&
scalarError ===
(overlay === 'temperature'
? 'Temperature field unavailable'
: 'Mean sea level pressure field unavailable');
const scalarValid =
overlay === 'none'
? scalar === undefined && scalarError === undefined
: optionalUnavailable ||
(scalarError === undefined &&
scalar?.kind === overlay &&
scalar.units === (overlay === 'temperature' ? '°C' : 'hPa') &&
scalar.level ===
(overlay === 'temperature'
? '2 m above ground'
: 'mean sea level'));
if (
manifest?.model !== model ||
(manifest.overlay ?? 'none') !== overlay ||
!scalarValid ||
!grid ||
!Number.isInteger(grid.nx) ||
!Number.isInteger(grid.ny) ||
grid.nx < 1 ||
grid.ny < 1 ||
grid.nx * grid.ny > 1_000_000 ||
![grid.lo1, grid.la1, grid.dx, grid.dy].every(Number.isFinite) ||
grid.dx <= 0 ||
grid.dy <= 0 ||
Math.abs(grid.nx * grid.dx - 360) > 0.01 ||
!new RegExp(
`^/api/wind/grid/${model}-[\\w.-]+\\.bin\\?${query}$`,
).test(manifest.gridUrl)
)
throw new Error('Malformed wind manifest');
const count = grid.nx * grid.ny;
const expectedBytes = count * (scalar ? 12 : 8);
const gridResponse = await fetchImpl(manifest.gridUrl, {
signal: active,
redirect: 'error',
});
if (!gridResponse.ok)
throw new Error(`Wind HTTP ${gridResponse.status}`);
const bytes = await readWindBody(gridResponse, expectedBytes, active);
if (bytes.byteLength !== expectedBytes)
throw new Error('Malformed wind grid');
const values = new Float32Array(bytes.buffer);
if (!values.every(Number.isFinite))
throw new Error('Malformed wind grid');
return {
...manifest,
u: values.slice(0, count),
v: values.slice(count, count * 2),
...(scalar
? { scalar: { ...scalar, values: values.slice(count * 2) } }
: {}),
};
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', abort);
}
},
};
}
+245
View File
@@ -0,0 +1,245 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindSource } from './source.js';
const manifest = {
model: 'gfs',
grid: { nx: 2, ny: 1, lo1: 0, la1: 90, dx: 180, dy: 180 },
gridUrl: '/api/wind/grid/gfs-test.bin?model=gfs',
};
const response = (value) =>
new Response(
typeof value === 'object' && !(value instanceof ArrayBuffer)
? JSON.stringify(value)
: value,
);
test('wind source splits a bounded field and preserves unavailable responses', async () => {
const source = createWindSource({
fetchImpl: async (url) =>
response(
url.includes('manifest')
? manifest
: Float32Array.from([1, 2, 3, 4]).buffer,
),
});
const field = await source.getSnapshot();
assert.deepEqual([...field.u], [1, 2]);
assert.deepEqual([...field.v], [3, 4]);
const unavailable = createWindSource({
fetchImpl: async () => response({ unavailable: true }),
});
assert.equal((await unavailable.getSnapshot()).unavailable, true);
});
test('wind source rejects foreign URLs, excessive grids and non-finite values', async () => {
for (const bad of [
{ ...manifest, gridUrl: 'https://example.com/exfil' },
{ ...manifest, grid: { ...manifest.grid, nx: 1e9 } },
]) {
let calls = 0;
const source = createWindSource({
fetchImpl: async () => {
calls++;
return response(bad);
},
});
await assert.rejects(source.getSnapshot(), /Malformed/);
assert.equal(calls, 1);
}
const source = createWindSource({
fetchImpl: async (url) =>
response(
url.includes('manifest')
? manifest
: Float32Array.from([NaN, 2, 3, 4]).buffer,
),
});
await assert.rejects(source.getSnapshot(), /Malformed/);
});
test('wind source timeout aborts the underlying fetch', async () => {
const source = createWindSource({
timeoutMs: 5,
fetchImpl: (_, { signal }) =>
new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
),
});
await assert.rejects(source.getSnapshot(), /timed out/);
});
test('wind source requests optional companions and splits exact U/V/scalar byte regions', async () => {
for (const [overlay, units, level, expected] of [
['temperature', '°C', '2 m above ground', [-5, 25]],
['pressure', 'hPa', 'mean sea level', [995, 1025]],
]) {
const scalar = { kind: overlay, units, level };
const variant = {
...manifest,
overlay,
scalar,
gridUrl: `/api/wind/grid/gfs-test-${overlay}.bin?model=gfs&overlay=${overlay}`,
};
const calls = [];
const source = createWindSource({
fetchImpl: async (url, options) => {
calls.push({ url, options });
return response(
url.includes('/manifest?')
? variant
: Float32Array.from([1, 2, 3, 4, ...expected]).buffer,
);
},
});
const field = await source.getSnapshot({ overlay });
assert.equal(
calls[0].url,
`/api/wind/manifest?model=gfs&overlay=${overlay}`,
);
assert.equal(calls[1].url, variant.gridUrl);
assert.ok(calls.every((call) => call.options.redirect === 'error'));
assert.deepEqual([...field.u], [1, 2]);
assert.deepEqual([...field.v], [3, 4]);
assert.deepEqual([...field.scalar.values], expected);
assert.equal(field.scalar.units, units);
assert.equal(field.scalar.level, level);
}
});
test('wind source rejects scalar contract mismatches before fetching a grid', async () => {
const base = {
...manifest,
overlay: 'temperature',
scalar: { kind: 'temperature', units: '°C', level: '2 m above ground' },
gridUrl:
'/api/wind/grid/gfs-test-temperature.bin?model=gfs&overlay=temperature',
};
for (const bad of [
{ ...base, overlay: 'pressure' },
{ ...base, scalar: { ...base.scalar, units: 'K' } },
{ ...base, scalar: { ...base.scalar, level: 'surface' } },
{ ...base, scalar: undefined },
{
...base,
gridUrl: base.gridUrl.replace('overlay=temperature', 'overlay=pressure'),
},
{ ...base, gridUrl: `https://example.com${base.gridUrl}` },
]) {
let calls = 0;
const source = createWindSource({
fetchImpl: async () => {
calls++;
return response(bad);
},
});
await assert.rejects(
source.getSnapshot({ overlay: 'temperature' }),
/Malformed/,
);
assert.equal(calls, 1);
}
let calls = 0;
const source = createWindSource({
fetchImpl: async () => {
calls++;
return response(base);
},
});
await assert.rejects(source.getSnapshot({ overlay: 'humidity' }), /Unknown/);
assert.equal(calls, 0);
await assert.rejects(
source.getSnapshot(),
/Malformed/,
'wind-only does not accept an unsolicited scalar',
);
});
test('wind source bounds optional scalar length and rejects non-finite scalar values', async () => {
const variant = {
...manifest,
overlay: 'pressure',
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
};
for (const values of [
[1, 2, 3, 4],
[1, 2, 3, 4, 1000, 1001, 1002],
[1, 2, 3, 4, NaN, 1000],
]) {
const source = createWindSource({
fetchImpl: async (url) =>
response(
url.includes('manifest') ? variant : Float32Array.from(values).buffer,
),
});
await assert.rejects(
source.getSnapshot({ overlay: 'pressure' }),
/Malformed|budget/,
);
}
});
test('cancelling an optional scalar stream cancels the active grid body', async () => {
const variant = {
...manifest,
overlay: 'pressure',
scalar: { kind: 'pressure', units: 'hPa', level: 'mean sea level' },
gridUrl: '/api/wind/grid/gfs-test-pressure.bin?model=gfs&overlay=pressure',
};
let started;
const ready = new Promise((resolve) => {
started = resolve;
});
let cancelled = false;
const source = createWindSource({
fetchImpl: async (url) => {
if (url.includes('manifest')) return response(variant);
return new Response(
new ReadableStream({
start() {
started();
},
cancel() {
cancelled = true;
},
}),
);
},
});
const controller = new AbortController();
const result = source.getSnapshot({
overlay: 'pressure',
signal: controller.signal,
});
await ready;
await Promise.resolve();
controller.abort(new Error('overlay changed'));
await assert.rejects(result, /overlay changed/);
assert.equal(cancelled, true);
});
test('wind source retains usable wind when the requested scalar is unavailable', async () => {
for (const overlay of ['temperature', 'pressure']) {
const partial = {
...manifest,
overlay,
scalarError:
overlay === 'temperature'
? 'Temperature field unavailable'
: 'Mean sea level pressure field unavailable',
gridUrl: `/api/wind/grid/gfs-test-${overlay}-wind-only.bin?model=gfs&overlay=${overlay}`,
};
const source = createWindSource({
fetchImpl: async (url) =>
response(
url.includes('manifest')
? partial
: Float32Array.from([1, 2, 3, 4]).buffer,
),
});
const result = await source.getSnapshot({ overlay });
assert.equal(result.scalar, undefined);
assert.equal(result.scalarError, partial.scalarError);
assert.deepEqual([...result.u], [1, 2]);
assert.deepEqual([...result.v], [3, 4]);
}
});
Loaded 100 of 359 files, more files were not shown because too many files have changed in this diff. Show more