diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 05d1b921c..cec55da33 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -228,8 +228,10 @@ one application, and only the globe-bound parts move to the next engine. (`src/globe/cesium/imagery.js`). - Navara draws them in one screen pass after tone mapping, classified on the scene depth (`src/globe/navara/imagery/`). - - Active Fires' far-zoom heatmap and Recent Imagery draw through it; - weather, wind and the Bhote Koshi event imagery do not yet. + - Active Fires' far-zoom heatmap, Recent Imagery, weather, the wind + field and the Bhote Koshi event imagery draw through it. Weather and + wind require the engine's imagery primitive; the manifest supplies + each engine's wind streamlines through the display's flow backend. - `engine.maps.addOverlay` (Navara's native raster layers for Sea Marks and Ski Pistes) stays until those overlays move to `engine.imagery`. - On November 7, 2026, `scripts/qa-ground-suite.mjs --only=imagery:` (the diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index c7fd438b2..6130f616d 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -445,9 +445,9 @@ near the camera from one 8×8 atlas over the whole view. - **The wind field** (`src/layers/wind/render/display.js`): one image over the globe at order 20, faded with the camera's height while streamlines draw, raised at 5 km on 3D tiles. The streamlines stay each engine's, - behind the display's `flow` backend: Cesium's wind renderer without its - field (`rendering.js`, from the layer), Navara's flow mesh - (`render/navaraFlow.js`, from the manifest). + behind the display's `flow` backend, loaded by the manifest: + `render/cesiumFlow.js` supplies Cesium's streamlines (`rendering.js`), + and `render/navaraFlow.js` supplies Navara's flow mesh. - **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/render/display.js`): two `url` images over the event's rectangle at alpha 0.96, split left and right at the engine's split position, order 15. The event's and the diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index 8c436368d..ead4bc184 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { - "cesiumLayerRendererLines": 330, + "cesiumLayerRendererLines": 344, "navaraLayerRendererLines": 1695, - "cesiumImportFiles": 27, - "viewerLines": 1232, + "cesiumImportFiles": 25, + "viewerLines": 1177, "navaraKindChecks": 2, "engineSniffCalls": 0, - "engineExtensionReads": 8, + "engineExtensionReads": 9, "groundEffectPasses": 1, "cesiumSdkLoads": 4, "screenSpaceHandlerFiles": 4, - "engineNameLines": 63 + "engineNameLines": 62 }, - "measuredAt": "after weather, wind and Bhote Koshi on engine.imagery, 2026-11-07" + "measuredAt": "Remove weather and wind fallback imagery; retain the explicit Cesium wind flow backend seam" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c032358f3..01967f992 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -834,7 +834,6 @@ "src/layers/weather/index.js", "src/layers/weather/clock.js", "src/layers/weather/clock.test.mjs", - "src/layers/weather/rendering.js", "src/layers/weather/shellRendering.js", "src/layers/weather/shellRendering.test.mjs", "src/layers/weather/infraredAlpha.js", @@ -843,7 +842,6 @@ "src/layers/weather/infraredImage.test.mjs", "src/layers/weather/source.js", "src/layers/weather/weather.test.mjs", - "src/layers/weather/rasterTiles.test.mjs", "src/layers/wind/rendering.test.mjs", "src/layers/weather/source.test.mjs", "src/data/weatherProxy.test.mjs", @@ -869,8 +867,6 @@ "src/ui/railCards.test.mjs", "src/ui/railTimeline.js", "src/ui/railTimeline.test.mjs", - "src/layers/weather/imageryOrder.js", - "src/layers/weather/imageryOrder.test.mjs", "server/providers/cyclones.js", "server/providers/firePerimeters.js", "server/providers/droneAirspace.js", @@ -1660,5 +1656,7 @@ "src/layers/bhoteKoshi/render/display.test.mjs", "src/layers/shared/render/navaraGroundOrder.test.mjs", "src/globe/navara/ground/corridors.js", - "scripts/qa-ground-looks.mjs" + "scripts/qa-ground-looks.mjs", + "src/layers/wind/render/cesiumFlow.js", + "src/testSupport/imageryDisplayEngines.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 96ef05401..9bdadf8c6 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2506,7 +2506,6 @@ "src/layers/wind/inspection.js", "src/layers/wind/presentation.js", "src/layers/wind/rendering.js", - "src/layers/weather/rasterTiles.js", "src/layers/wind/source.js", "src/sources/windBody.js", "src/data/detection.js", @@ -3128,7 +3127,6 @@ "src/voice/realtimeConnection.js", "src/layers/weather/index.js", "src/layers/weather/clock.js", - "src/layers/weather/rendering.js", "src/layers/weather/display.js", "src/layers/weather/shellRendering.js", "src/layers/weather/infraredAlpha.js", @@ -3163,7 +3161,6 @@ "src/scenes/sharing.js", "src/ui/sceneSharing.js", "src/ui/stores/sceneSharing.js", - "src/layers/weather/imageryOrder.js", "src/layers/weather/imageryHost.js", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", @@ -3580,7 +3577,8 @@ "src/globe/imagery.js", "src/globe/cesium/imagery.js", "src/globe/cesium/imageryShell.js", - "src/globe/navara/ground/corridors.js" + "src/globe/navara/ground/corridors.js", + "src/layers/wind/render/cesiumFlow.js" ], "external": [ "hls.js", @@ -3641,7 +3639,6 @@ "src/layers/wind/inspection.js", "src/layers/wind/presentation.js", "src/layers/wind/rendering.js", - "src/layers/weather/rasterTiles.js", "src/app/layers/aisLiveVessels.js", "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", @@ -4062,13 +4059,11 @@ "src/sources/overpassRoads.js", "src/layers/weather/index.js", "src/layers/weather/clock.js", - "src/layers/weather/rendering.js", "src/layers/weather/display.js", "src/layers/weather/shellRendering.js", "src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredImage.js", "src/layers/weather/source.js", - "src/layers/weather/imageryOrder.js", "src/layers/weather/imageryHost.js", "src/layers/cyclones/index.js", "src/layers/cyclones/rendering.js", @@ -4330,7 +4325,8 @@ "src/layers/shared/render/navaraGroundSurface.js", "src/layers/wind/render/flowMesh.js", "src/layers/shared/render/navaraGroundEffect.js", - "src/layers/awareness/render/navaraPulses.js" + "src/layers/awareness/render/navaraPulses.js", + "src/layers/wind/render/cesiumFlow.js" ], "external": [ "hls.js", diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 5c925898c..52e252f1e 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -130,6 +130,11 @@ const navaraStory = async () => { drawStorySource(engine, navaraNative(engine), data), }; }; +const cesiumWindFlow = async () => { + const { createWindCesiumFlow } = + await import('../../layers/wind/render/cesiumFlow.js'); + return { flow: createWindCesiumFlow }; +}; const navaraWindFlow = async () => { const { createWindNavaraFlow } = await import('../../layers/wind/render/navaraFlow.js'); @@ -201,9 +206,12 @@ export const LAYER_MANIFEST = Object.freeze({ }), }), // The wind field and the weather rasters on engine.imagery; the wind's - // streamlines are each engine's (Cesium's from the layer, Navara's flow - // mesh). - wind: display('createWindDisplay', ['imagery'], { navara: navaraWindFlow }), + // streamlines are each engine's backend (Cesium's wind renderer, Navara's + // flow mesh). + wind: display('createWindDisplay', ['imagery'], { + cesium: cesiumWindFlow, + navara: navaraWindFlow, + }), 'weather-radar': display('createWeatherDisplay', ['imagery']), 'weather-satellite': display('createWeatherDisplay', ['imagery']), 'weather-lightning': display('createWeatherDisplay', ['imagery']), diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 03c463e7c..af1397af0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1886,7 +1886,6 @@ "Infrared decode failed": "فشل فك ترميز صورة الأشعة تحت الحمراء", "Weather prefetch unavailable": "الجلب المسبق للطقس غير متاح", "Weather tiles unavailable · previous frame retained": "بلاطات الطقس غير متاحة · أُبقي على الإطار السابق", - "Some weather tiles unavailable": "بعض بلاطات الطقس غير متاحة", "Malformed weather manifest": "بيان الطقس مشوّه", "Weather request timed out": "انتهت مهلة طلب الطقس", "Temperature field unavailable": "حقل درجة الحرارة غير متاح", @@ -1958,8 +1957,6 @@ "WIND AT {coordinates}": "الرياح عند {coordinates}", "{model} · valid {time}": "{model} · التوقع عند {time}", "Wind speed field unavailable": "حقل سرعة الرياح غير متاح", - "Globe imagery unavailable": "صور الكرة الأرضية غير متاحة", - "Globe field image unavailable": "صورة الحقل على الكرة الأرضية غير متاحة", "Unknown wind model": "نموذج رياح غير معروف", "Unknown weather overlay": "طبقة طقس غير معروفة", "Wind request timed out": "انتهت مهلة طلب الرياح", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b0883a814..92832ec3a 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1806,7 +1806,6 @@ "Infrared decode failed": "Infrarot-Dekodierung fehlgeschlagen", "Weather prefetch unavailable": "Wetter-Vorabruf nicht verfügbar", "Weather tiles unavailable · previous frame retained": "Wetterkacheln nicht verfügbar · vorheriger Frame bleibt erhalten", - "Some weather tiles unavailable": "Einige Wetterkacheln nicht verfügbar", "Malformed weather manifest": "Fehlerhaftes Wetter-Manifest", "Weather request timed out": "Zeitüberschreitung bei der Wetterabfrage", "Temperature field unavailable": "Temperaturfeld nicht verfügbar", @@ -1878,8 +1877,6 @@ "WIND AT {coordinates}": "WIND BEI {coordinates}", "{model} · valid {time}": "{model} · gültig {time}", "Wind speed field unavailable": "Feld der Windgeschwindigkeit nicht verfügbar", - "Globe imagery unavailable": "Globusbilder nicht verfügbar", - "Globe field image unavailable": "Feldbild für den Globus nicht verfügbar", "Unknown wind model": "Unbekanntes Windmodell", "Unknown weather overlay": "Unbekanntes Wetter-Overlay", "Wind request timed out": "Zeitüberschreitung bei der Windabfrage", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 15ae40010..c7f9f79b6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2022,11 +2022,8 @@ "Weather source unavailable; previous observation retained": "Fuente meteorológica no disponible; se mantiene la observación anterior", "Weather unavailable": "Clima no disponible", "Weather tiles unavailable · previous frame retained": "Teselas meteorológicas no disponibles · se mantiene el fotograma anterior", - "Some weather tiles unavailable": "Algunas teselas meteorológicas no disponibles", "Hidden by this map source · choose a globe map": "Oculto con esta fuente del mapa · elige un mapa de globo", "Wind source unavailable": "Fuente de viento no disponible", - "Globe imagery unavailable": "Imágenes del globo no disponibles", - "Globe field image unavailable": "Imagen del campo en el globo no disponible", "status chip|UNAVAILABLE": "NO DISP.", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Contiene referencias a contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.", "Selected Space Mission": "Misión espacial seleccionada", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2a17f0f7c..4e8efd5e6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1262,7 +1262,6 @@ "Infrared decode failed": "Échec du décodage infrarouge", "Weather prefetch unavailable": "Préchargement météo indisponible", "Weather tiles unavailable · previous frame retained": "Tuiles indisp. · image précédente conservée", - "Some weather tiles unavailable": "Certaines tuiles météo indisponibles", "Malformed weather manifest": "Manifeste météo mal formé", "Weather request timed out": "Délai dépassé pour la requête météo", "Temperature field unavailable": "Champ de température indisponible", @@ -1336,8 +1335,6 @@ "WIND AT {coordinates}": "VENT À {coordinates}", "{model} · valid {time}": "{model} · valide {time}", "Wind speed field unavailable": "Champ de vitesse du vent indisponible", - "Globe imagery unavailable": "Imagerie du globe indisponible", - "Globe field image unavailable": "Image du champ sur le globe indisponible", "Unknown wind model": "Modèle de vent inconnu", "Unknown weather overlay": "Surcouche météo inconnue", "Wind request timed out": "Délai dépassé pour la requête de vent", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index afd830188..e711c7d12 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1262,7 +1262,6 @@ "Infrared decode failed": "Decodifica dell'infrarosso non riuscita", "Weather prefetch unavailable": "Precaricamento meteo non disponibile", "Weather tiles unavailable · previous frame retained": "Tile meteo non disponibili · mantenuto il fotogramma precedente", - "Some weather tiles unavailable": "Alcuni tile meteo non sono disponibili", "Malformed weather manifest": "Manifest meteo non valido", "Weather request timed out": "La richiesta meteo è andata in timeout", "Temperature field unavailable": "Campo di temperatura non disponibile", @@ -1336,8 +1335,6 @@ "WIND AT {coordinates}": "VENTO A {coordinates}", "{model} · valid {time}": "{model} · valida {time}", "Wind speed field unavailable": "Campo della velocità del vento non disponibile", - "Globe imagery unavailable": "Immagini del globo non disponibili", - "Globe field image unavailable": "Immagine del campo sul globo non disponibile", "Unknown wind model": "Modello del vento sconosciuto", "Unknown weather overlay": "Overlay meteo sconosciuto", "Wind request timed out": "La richiesta del vento è andata in timeout", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 8c12c185c..c185f32a4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1230,7 +1230,6 @@ "Infrared decode failed": "赤外画像のデコードに失敗しました", "Weather prefetch unavailable": "気象のプリフェッチを利用できません", "Weather tiles unavailable · previous frame retained": "気象タイルを利用できません · 前のフレームを保持", - "Some weather tiles unavailable": "一部の気象タイルを利用できません", "Malformed weather manifest": "気象マニフェストの形式が不正です", "Weather request timed out": "気象リクエストがタイムアウトしました", "Temperature field unavailable": "気温場を利用できません", @@ -1304,8 +1303,6 @@ "WIND AT {coordinates}": "{coordinates}の風", "{model} · valid {time}": "{model} · 対象時刻 {time}", "Wind speed field unavailable": "風速場を利用できません", - "Globe imagery unavailable": "地球儀の画像を利用できません", - "Globe field image unavailable": "地球儀の要素画像を利用できません", "Unknown wind model": "不明な風モデル", "Unknown weather overlay": "不明な気象オーバーレイ", "Wind request timed out": "風のリクエストがタイムアウトしました", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 20b1aaeb9..a269cdbbe 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1260,7 +1260,6 @@ "Infrared decode failed": "Falha ao decodificar o infravermelho", "Weather prefetch unavailable": "Pré-carregamento do clima indisponível", "Weather tiles unavailable · previous frame retained": "Blocos meteorológicos indisponíveis · o quadro anterior foi mantido", - "Some weather tiles unavailable": "Alguns blocos meteorológicos indisponíveis", "Malformed weather manifest": "Manifesto do clima malformado", "Weather request timed out": "A requisição do clima excedeu o tempo limite", "Cyclone request timed out": "A requisição de ciclones excedeu o tempo limite", @@ -1336,8 +1335,6 @@ "WIND AT {coordinates}": "VENTO EM {coordinates}", "{model} · valid {time}": "{model} · válida para {time}", "Wind speed field unavailable": "Campo de velocidade do vento indisponível", - "Globe imagery unavailable": "Imagens do globo indisponíveis", - "Globe field image unavailable": "Imagem do campo no globo indisponível", "Unknown wind model": "Modelo de vento desconhecido", "Unknown weather overlay": "Camada de clima desconhecida", "Wind request timed out": "A requisição de vento excedeu o tempo limite", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ca6e4f5a8..1e9e8109c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2056,11 +2056,8 @@ "Weather source unavailable; previous observation retained": "Источник погоды недоступен; показано предыдущее наблюдение", "Weather unavailable": "Погода недоступна", "Weather tiles unavailable · previous frame retained": "Погодные тайлы недоступны · показан предыдущий кадр", - "Some weather tiles unavailable": "Часть погодных тайлов недоступна", "Hidden by this map source · choose a globe map": "Скрыто этим источником карты · выберите глобус", "Wind source unavailable": "Источник ветра недоступен", - "Globe imagery unavailable": "Снимки глобуса недоступны", - "Globe field image unavailable": "Изображение поля на глобусе недоступно", "status chip|UNAVAILABLE": "НЕДОСТ.", "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Используется зарегистрированный контент или связанные медиафайлы. Эти внешние файлы не входят в архив сцены. Их исходные условия использования по-прежнему действуют.", "Selected Space Mission": "Выбранная космическая миссия", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 14d323a85..4de05a6cb 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1277,7 +1277,6 @@ "Infrared decode failed": "Не вдалося декодувати інфрачервоний знімок", "Weather prefetch unavailable": "Попереднє завантаження погоди недоступне", "Weather tiles unavailable · previous frame retained": "Погодні тайли недоступні · показано попередній кадр", - "Some weather tiles unavailable": "Частина погодних тайлів недоступна", "Malformed weather manifest": "Некоректний маніфест погоди", "Weather request timed out": "Минув час очікування запиту погоди", "Temperature field unavailable": "Поле температури недоступне", @@ -1351,8 +1350,6 @@ "WIND AT {coordinates}": "ВІТЕР У ТОЧЦІ {coordinates}", "{model} · valid {time}": "{model} · на {time}", "Wind speed field unavailable": "Поле швидкості вітру недоступне", - "Globe imagery unavailable": "Знімки глобуса недоступні", - "Globe field image unavailable": "Зображення поля на глобусі недоступне", "Unknown wind model": "Невідома модель вітру", "Unknown weather overlay": "Невідомий погодний шар", "Wind request timed out": "Минув час очікування запиту вітру", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 01f7f0cac..bffad6a7d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1206,7 +1206,6 @@ "Infrared decode failed": "红外图像解码失败", "Weather prefetch unavailable": "天气预取不可用", "Weather tiles unavailable · previous frame retained": "天气瓦片不可用 · 保留上一帧", - "Some weather tiles unavailable": "部分天气瓦片不可用", "Malformed weather manifest": "天气清单格式错误", "Weather request timed out": "天气请求超时", "Cyclone request timed out": "气旋请求超时", @@ -1278,8 +1277,6 @@ "WIND AT {coordinates}": "{coordinates} 的风", "{model} · valid {time}": "{model} · 有效时间 {time}", "Wind speed field unavailable": "风速场不可用", - "Globe imagery unavailable": "地球影像不可用", - "Globe field image unavailable": "地球气象场图像不可用", "Unknown wind model": "未知风场模式", "Unknown weather overlay": "未知天气叠加层", "Wind request timed out": "风场请求超时", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e08a2ea8c..69ee36b39 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1201,7 +1201,6 @@ "Infrared decode failed": "紅外圖像解碼失敗", "Weather prefetch unavailable": "天氣預取不可用", "Weather tiles unavailable · previous frame retained": "天氣瓦片不可用 · 保留上一幀", - "Some weather tiles unavailable": "部分天氣瓦片不可用", "Malformed weather manifest": "天氣清單格式錯誤", "Weather request timed out": "天氣請求超時", "Cyclone request timed out": "氣旋請求超時", @@ -1273,8 +1272,6 @@ "WIND AT {coordinates}": "{coordinates} 的風", "{model} · valid {time}": "{model} · 有效時間 {time}", "Wind speed field unavailable": "風速場不可用", - "Globe imagery unavailable": "地球影像不可用", - "Globe field image unavailable": "地球氣象場圖像不可用", "Unknown wind model": "未知風場模式", "Unknown weather overlay": "未知天氣疊加層", "Wind request timed out": "風場請求超時", diff --git a/src/layers/weather/display.test.mjs b/src/layers/weather/display.test.mjs index e852a1ae8..6b0ae5341 100644 --- a/src/layers/weather/display.test.mjs +++ b/src/layers/weather/display.test.mjs @@ -1,11 +1,10 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { GeographicTilingScheme } from 'cesium'; +import * as geo from '../../geo/index.js'; +import { imageryDisplayTests } from '../../testSupport/imageryDisplayEngines.mjs'; +const { test, createEngine } = imageryDisplayTests(); import { createWeatherLayer } from './index.js'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherDisplay } from './render/display.js'; import { weatherDisplay, insideExtent, escapeCreditText } from './display.js'; -import { createShellCesium, createShellScene } from './shellFixture.mjs'; import { formatScaleLabel } from '../../ui/unitLabels.js'; const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z']; @@ -25,14 +24,29 @@ const quiet = () => ({ removeEventListener() {}, }); +/** A rectangle in degrees as the engine camera takes it, in radians. */ +const radians = (west, south, east, north) => ({ + west: geo.math.toRadians(west), + south: geo.math.toRadians(south), + east: geo.math.toRadians(east), + north: geo.math.toRadians(north), +}); + /** - * A layer whose renderer shows every requested frame at once. `center` is the - * map centre as [lon, lat]; `manifests` maps a product to its manifest, and - * may be changed between updates. + * A layer on the fake and headless Cesium engines whose renderer shows every requested frame at + * once. `center` is the map centre as [lon, lat]; `manifests` maps a product + * to its manifest, and may be changed between updates. */ async function shownLayer(id, manifest, params, view = {}) { let time = null; const flights = []; + const engine = createEngine({ + canvas: { clientWidth: 800, clientHeight: 600 }, + }); + engine.fake.script('pickEllipsoid', () => + view.center ? geo.cartesian3.fromDegrees(...view.center) : undefined, + ); + engine.camera.flyTo = ({ destination }) => flights.push(destination); const snapshots = []; const manifests = { [manifest.product]: manifest }; const layer = createWeatherLayer({ @@ -41,7 +55,7 @@ async function shownLayer(id, manifest, params, view = {}) { documentRef: quiet(), eventTarget: quiet(), matchMedia: () => quiet(), - createRendering: () => ({ + createNativeRendering: () => ({ rehome() {}, setAlpha() {}, setHidden() {}, @@ -58,18 +72,7 @@ async function shownLayer(id, manifest, params, view = {}) { getDiagnostics: () => ({ time, loading: false, error: null }), }), }); - layer.init({ - camera: { - moveEnd: { addEventListener: () => () => {} }, - flyTo: ({ destination }) => flights.push(destination), - pickEllipsoid: () => - view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined, - }, - scene: { - canvas: { clientWidth: 800, clientHeight: 600 }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - }, - }); + layer.init(null, { engine }); if (params) layer.setParams(params); layer.enable(); await layer.update(); @@ -215,10 +218,7 @@ for (const [id, product, params] of PRODUCTS) { ); assertBuiltIn(layer, product); layer.setParams({ focus: true }); - assert.deepEqual( - flights[0], - Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus), - ); + assert.deepEqual(flights[0], radians(...BUILT_IN[product].focus)); layer.destroy(); }); } @@ -288,10 +288,7 @@ test('valid display metadata describes the product in the row, legend and stats' assert.equal(stats.source, 'Example network'); assert.equal(stats.sourceVerbatim, true); layer.setParams({ focus: true }); - assert.deepEqual( - flights[0], - Cesium.Rectangle.fromDegrees(-180, -52, 180, 52), - ); + assert.deepEqual(flights[0], radians(-180, -52, 180, 52)); layer.destroy(); }); @@ -593,101 +590,77 @@ function mockCanvas() { return canvas; } -/** A globe viewer whose imagery collection records providers. */ -function globeViewer() { - const providers = []; - const layers = []; - const collection = { - addImageryProvider(provider) { - providers.push(provider); - const layer = { imageryProvider: provider, alpha: 1, show: true }; - layers.push(layer); - return layer; - }, - contains: (layer) => layers.includes(layer), - remove(layer) { - layers.splice(layers.indexOf(layer), 1); - return true; - }, - get length() { - return layers.length; - }, - get: (index) => layers[index], - indexOf: (layer) => layers.indexOf(layer), - lower() {}, - raise() {}, - raiseToTop() {}, - isDestroyed: () => false, - }; - const listeners = new Set(); - const viewer = { - imageryLayers: collection, - camera: { - moveEnd: { addEventListener: () => () => {} }, - flyTo() {}, - }, - scene: { - postRender: { - addEventListener(fn) { - listeners.add(fn); - return () => listeners.delete(fn); - }, - }, - primitives: createShellScene().primitives, - globe: { tilesLoaded: true }, - requestRender() {}, - }, - }; - class Provider { - constructor(options) { - this.options = options; - this.tilingScheme = new GeographicTilingScheme(); - this.errorEvent = new Cesium.Event(); - } - requestImage() { - return undefined; - } - } - const cesium = { - ...createShellCesium(), - UrlTemplateImageryProvider: Provider, - GeographicTilingScheme, - Event: Cesium.Event, - Credit: Cesium.Credit, - Rectangle: Cesium.Rectangle, +/** + * The fake engine on the globe, its surface switchable to 3D tiles + * (`setSurface`); `stages` records every staged source of every imagery + * layer, and `options` are the display's source seams. Images decode at + * `size`: the global mosaic's, else a shell image's. + */ +function globeEngine({ size = [2048, 1024] } = {}) { + const engine = createEngine(); + let surface = 'globe'; + engine.imagery.surface = () => surface; + const stages = []; + const createLayer = engine.imagery.createLayer; + engine.imagery.createLayer = (layerOptions) => { + const layer = createLayer(layerOptions); + const setSource = layer.setSource; + layer.setSource = (source, sourceOptions) => { + if (sourceOptions?.staged) stages.push({ layer: layerOptions, source }); + return setSource(source, sourceOptions); + }; + return layer; }; const options = { - cesium, fetchImpl: async () => ({ ok: true, headers: new Headers(), arrayBuffer: async () => new ArrayBuffer(1), }), - decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), + decodeImage: async () => ({ + width: size[0], + height: size[1], + close() {}, + }), createCanvas: mockCanvas, }; const settle = async () => { - await flush(); - for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn(); - await flush(); + for (let i = 0; i < 4; i++) { + await flush(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + }; + /** The draped layers' staged sources, in order. */ + const draped = () => stages.filter(({ layer }) => layer.raise == null); + const shown = () => { + const [id] = engine.fake.imageryLayers(); + return id ? engine.fake.imageryLayer(id) : undefined; + }; + return { + engine, + stages, + draped, + options, + settle, + setSurface(value) { + surface = value; + }, + credit: () => shown()?.source.credit, + shown, }; - return { viewer, providers, options, settle }; } -/** Show a frame of `manifest` and return the rendering and its globe. */ +/** Show a frame of `manifest` and return the display and its engine. */ async function shownFrame(manifest) { - const globe = globeViewer(); - const rendering = createWeatherRendering({ - viewer: globe.viewer, - ...globe.options, - }); - const shown = rendering.setFrame(manifest, times[1]); + const globe = globeEngine(); + const display = createWeatherDisplay(globe.engine, globe.options); + const shown = display.setFrame(manifest, times[1]); await globe.settle(); assert.equal(await shown, true); - return { ...globe, rendering }; + return { ...globe, display }; } - -const creditOf = (providers) => providers.at(-1).credit.html; +const sameBox = (actual, { west, south, east, north }, message) => + assert.deepEqual({ ...actual }, { west, south, east, north }, message); test('the imagery credit uses the source credit as plain text, else the built-in one', async () => { for (const product of ['radar', 'lightning', 'clouds-regional', 'clouds']) { @@ -697,19 +670,16 @@ test('the imagery credit uses the source credit as plain text, else the built-in product, ...(display ? { display } : {}), }); - assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product); - h.rendering.clear(); + assert.equal(h.credit(), BUILT_IN[product].credit, product); + h.display.clear(); } const h = await shownFrame({ ...base, product, display: { credit: 'Example & partners' }, }); - assert.equal( - creditOf(h.providers), - 'Example <network> & partners', - ); - h.rendering.clear(); + assert.equal(h.credit(), 'Example <network> & partners'); + h.display.clear(); } }); @@ -720,61 +690,44 @@ test('a refreshed manifest updates the credit of the frame already shown', async product, display: { credit: 'First credit' }, }); - assert.equal(creditOf(h.providers), 'First credit'); + assert.equal(h.credit(), 'First credit'); const again = (display) => - h.rendering.setFrame( + h.display.setFrame( { ...base, product, ...(display ? { display } : {}) }, times[1], ); assert.equal(await again({ credit: 'Second credit' }), true); - assert.equal(h.providers.length, 1, 'the frame is not restaged'); - assert.equal(creditOf(h.providers), 'Second credit'); + assert.equal(h.stages.length, 1, 'the frame is not restaged'); + assert.equal(h.credit(), 'Second credit'); assert.equal(await again(), true); - assert.equal(creditOf(h.providers), BUILT_IN[product].credit); - h.rendering.updateSnapshot({ + assert.equal(h.credit(), BUILT_IN[product].credit); + h.display.updateSnapshot({ ...base, product, display: { credit: 'Third credit' }, }); - assert.equal(creditOf(h.providers), 'Third credit'); + assert.equal(h.credit(), 'Third credit'); // Another product's manifest leaves this frame alone. - h.rendering.updateSnapshot({ + h.display.updateSnapshot({ ...base, product: 'radar', display: { credit: 'Other' }, }); - assert.equal(creditOf(h.providers), 'Third credit'); - h.rendering.clear(); + assert.equal(h.credit(), 'Third credit'); + assert.equal(h.stages.length, 1); + h.display.clear(); } }); test('a refreshed manifest leaves the shown snapshot for a host switch to restage', async () => { - const globe = globeViewer(); - let host = { collection: globe.viewer.imageryLayers, kind: 'globe' }; - const staged = []; - const rendering = createWeatherRendering({ - viewer: globe.viewer, - ...globe.options, - getHost: () => host, - createShell: ({ product }) => ({ - product, - setAlpha() {}, - setHidden() {}, - rehome: () => false, - cancelPrefetch() {}, - clear() {}, - async setFrame(snapshot, time) { - staged.push([snapshot, time]); - return true; - }, - }), - }); + const globe = globeEngine({ size: [4096, 2048] }); + const display = createWeatherDisplay(globe.engine, globe.options); const first = { ...base, product: 'lightning', display: { credit: 'First credit' }, }; - const shown = rendering.setFrame(first, times[0]); + const shown = display.setFrame(first, times[0]); await globe.settle(); assert.equal(await shown, true); const refreshed = { @@ -783,131 +736,101 @@ test('a refreshed manifest leaves the shown snapshot for a host switch to restag bounds: { west: -10, south: 0, east: 10, north: 5 }, display: { credit: 'Second credit' }, }; - rendering.updateSnapshot(refreshed); - assert.equal(creditOf(globe.providers), 'Second credit'); - host = { collection: {}, kind: 'tileset' }; - assert.equal(rendering.rehome(), true); - await flush(); - assert.deepEqual(staged, [[first, times[0]]]); - host = { collection: globe.viewer.imageryLayers, kind: 'globe' }; - assert.equal(rendering.rehome(), true); + display.updateSnapshot(refreshed); + assert.equal(globe.credit(), 'Second credit'); + globe.setSurface('tiles'); + assert.equal(display.rehome(), true); await globe.settle(); - assert.equal(globe.providers.length, 2); - const { west, south, east, north } = first.bounds; - assert.ok( - Cesium.Rectangle.equals( - globe.providers.at(-1).options.rectangle, - Cesium.Rectangle.fromDegrees(west, south, east, north), - ), + // The shell restages the frame as it was staged: its time and bounds. + const shell = globe.shown(); + assert.equal(shell.raise, 6_600); + sameBox(shell.source.rectangle, first.bounds); + assert.equal(display.getDiagnostics().host, 'shell'); + assert.equal(display.getDiagnostics().time, times[0]); + globe.setSurface('globe'); + assert.equal(display.rehome(), true); + await globe.settle(); + assert.equal(globe.draped().length, 2); + const restaged = globe.shown(); + assert.equal(restaged.raise, null); + sameBox( + restaged.source.rectangle, + first.bounds, 'the restaged frame keeps the bounds it was staged from', ); - assert.equal(creditOf(globe.providers), 'Second credit'); - rendering.clear(); + assert.match(restaged.source.url, /time=2026-09-15T20%3A00/); + assert.equal(globe.credit(), 'Second credit'); + display.clear(); }); test('a refresh during a host-switch restage keeps the newest credit', async () => { - const globe = globeViewer(); - let host = { collection: globe.viewer.imageryLayers, kind: 'globe' }; - const rendering = createWeatherRendering({ - viewer: globe.viewer, - ...globe.options, - getHost: () => host, - createShell: ({ product }) => ({ - product, - setAlpha() {}, - setHidden() {}, - rehome: () => false, - cancelPrefetch() {}, - clear() {}, - async setFrame() { - return true; - }, - }), - }); + const globe = globeEngine({ size: [4096, 2048] }); + const display = createWeatherDisplay(globe.engine, globe.options); const first = { ...base, product: 'lightning', display: { credit: 'First credit' }, }; - const shown = rendering.setFrame(first, times[0]); + const shown = display.setFrame(first, times[0]); await globe.settle(); assert.equal(await shown, true); - rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } }); - host = { collection: {}, kind: 'tileset' }; - assert.equal(rendering.rehome(), true); - await flush(); - host = { collection: globe.viewer.imageryLayers, kind: 'globe' }; - assert.equal(rendering.rehome(), true); - rendering.updateSnapshot({ ...first, display: { credit: 'Third credit' } }); + display.updateSnapshot({ ...first, display: { credit: 'Second credit' } }); + globe.setSurface('tiles'); + assert.equal(display.rehome(), true); await globe.settle(); - assert.equal(globe.providers.length, 2); - assert.equal(creditOf(globe.providers), 'Third credit'); - rendering.clear(); + globe.setSurface('globe'); + assert.equal(display.rehome(), true); + display.updateSnapshot({ ...first, display: { credit: 'Third credit' } }); + await globe.settle(); + assert.equal(globe.draped().length, 2); + assert.equal(globe.credit(), 'Third credit'); + display.clear(); }); test('a refresh while a frame loads survives a later host switch', async () => { - const globe = globeViewer(); - let host = { collection: globe.viewer.imageryLayers, kind: 'globe' }; - const rendering = createWeatherRendering({ - viewer: globe.viewer, - ...globe.options, - getHost: () => host, - createShell: ({ product }) => ({ - product, - setAlpha() {}, - setHidden() {}, - rehome: () => false, - cancelPrefetch() {}, - clear() {}, - async setFrame() { - return true; - }, - }), - }); + const globe = globeEngine({ size: [4096, 2048] }); + const display = createWeatherDisplay(globe.engine, globe.options); const first = { ...base, product: 'lightning', display: { credit: 'First credit' }, }; - const shown = rendering.setFrame(first, times[0]); - rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } }); + const shown = display.setFrame(first, times[0]); + display.updateSnapshot({ ...first, display: { credit: 'Second credit' } }); await globe.settle(); assert.equal(await shown, true); - assert.equal(creditOf(globe.providers), 'Second credit'); - host = { collection: {}, kind: 'tileset' }; - assert.equal(rendering.rehome(), true); - await flush(); - host = { collection: globe.viewer.imageryLayers, kind: 'globe' }; - assert.equal(rendering.rehome(), true); + assert.equal(globe.credit(), 'Second credit'); + globe.setSurface('tiles'); + assert.equal(display.rehome(), true); await globe.settle(); - assert.equal(globe.providers.length, 2); - assert.equal(creditOf(globe.providers), 'Second credit'); - rendering.clear(); + globe.setSurface('globe'); + assert.equal(display.rehome(), true); + await globe.settle(); + assert.equal(globe.draped().length, 2); + assert.equal(globe.credit(), 'Second credit'); + display.clear(); }); test('a refresh before a frame is installed gives it the newest credit', async () => { for (const product of ['lightning', 'clouds']) { - const globe = globeViewer(); - const rendering = createWeatherRendering({ - viewer: globe.viewer, - ...globe.options, - }); + const globe = globeEngine(); + const display = createWeatherDisplay(globe.engine, globe.options); const first = { ...base, product, display: { credit: 'First credit' } }; - const shown = rendering.setFrame(first, times[1]); - rendering.updateSnapshot({ + const shown = display.setFrame(first, times[1]); + display.updateSnapshot({ ...first, display: { credit: 'Second credit' }, }); await globe.settle(); assert.equal(await shown, true); - assert.equal(creditOf(globe.providers), 'Second credit', product); - rendering.clear(); + assert.equal(globe.credit(), 'Second credit', product); + display.clear(); } }); test('a layer refresh with the same latest frame updates the imagery credit', async (t) => { t.mock.method(Date, 'now', () => Date.parse(times[1])); - const globe = globeViewer(); + const globe = globeEngine(); let manifest = { ...base, product: 'lightning', @@ -919,21 +842,21 @@ test('a layer refresh with the same latest frame updates the imagery credit', as documentRef: quiet(), eventTarget: quiet(), matchMedia: () => quiet(), - createRendering: (args) => - createWeatherRendering({ ...args, ...globe.options }), + createNativeRendering: (engine, options) => + createWeatherDisplay(engine, { ...options, ...globe.options }), }); - layer.init(globe.viewer); + layer.init(null, { engine: globe.engine }); layer.enable(); const first = layer.update(); await globe.settle(); await first; - assert.equal(creditOf(globe.providers), 'First credit'); + assert.equal(globe.credit(), 'First credit'); manifest = { ...manifest, display: { credit: 'Second credit' } }; await layer.update(); - assert.equal(globe.providers.length, 1, 'the frame is not restaged'); - assert.equal(creditOf(globe.providers), 'Second credit'); + assert.equal(globe.stages.length, 1, 'the frame is not restaged'); + assert.equal(globe.credit(), 'Second credit'); manifest = { ...base, product: 'lightning' }; await layer.update(); - assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit); + assert.equal(globe.credit(), BUILT_IN.lightning.credit); layer.destroy(); }); diff --git a/src/layers/weather/imageryOrder.js b/src/layers/weather/imageryOrder.js deleted file mode 100644 index 49e1dea77..000000000 --- a/src/layers/weather/imageryOrder.js +++ /dev/null @@ -1,17 +0,0 @@ -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); -} diff --git a/src/layers/weather/imageryOrder.test.mjs b/src/layers/weather/imageryOrder.test.mjs deleted file mode 100644 index dcf0edb79..000000000 --- a/src/layers/weather/imageryOrder.test.mjs +++ /dev/null @@ -1,72 +0,0 @@ -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); -}); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 4a99269de..3a2fe997e 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -1,6 +1,5 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; -import { createWeatherRendering } from './rendering.js'; +import { createWeatherDisplay } from './render/display.js'; import { engineImageryHost, imageryHostStatus } from './imageryHost.js'; import { weatherDisplay, insideExtent } from './display.js'; import { @@ -53,14 +52,15 @@ const utc = (value) => : t('Unavailable'); /** A per-application observation layer, using the existing layer lifecycle and - * row controls. History is transient: shared links always open latest imagery. */ + * row controls. History is transient: shared links always open latest imagery. + * It draws through its display (render/display.js) on the engine it is + * attached to: `createNativeRendering(engine, options)`, the application's, + * or the display made here. */ export function createWeatherLayer({ feed, clock, id = 'weather-radar', - cesium = null, - createRendering = createWeatherRendering, - createNativeRendering = null, + createNativeRendering = createWeatherDisplay, documentRef = globalThis.document, eventTarget = globalThis.window, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -75,8 +75,7 @@ export function createWeatherLayer({ let infrared = 100; let sceneEngine = null; let lastShown = null; - let viewer = null, - rendering = null, + let rendering = null, manifest = null, request = null, listener = null; @@ -90,7 +89,6 @@ export function createWeatherLayer({ removeCamera = null; let motion = null; let runNavigation = null; - let imageryHost = null; let hostCollection; let hostHidden = false; let hostStatus = null; @@ -107,16 +105,12 @@ export function createWeatherLayer({ : REGIONAL_INFRARED_MAX_GAP_MS; const isLatest = () => clock ? clock.getState().mode === 'latest' : followLatest; - // A display draws through engine.imagery, which follows the surface the - // map shows; the Cesium renderer of old resolves Cesium's imagery stack. - let drawsOnEngine = false; + // The display draws through engine.imagery, which follows the surface the + // map shows; without an engine nothing draws and nothing is hidden. const getHost = () => - drawsOnEngine + sceneEngine ? engineImageryHost(sceneEngine) - : (imageryHost?.() ?? { - collection: viewer?.imageryLayers, - kind: 'globe', - }); + : { collection: null, kind: 'globe' }; const notify = () => listener?.(); const shownTime = () => noFrame ? null : (rendering?.getDiagnostics().time ?? lastShown); @@ -285,7 +279,7 @@ export function createWeatherLayer({ } } function watchCamera() { - const moveEnd = sceneEngine?.camera?.moveEnd ?? viewer?.camera?.moveEnd; + const moveEnd = sceneEngine?.camera.moveEnd; removeCamera ??= moveEnd?.addEventListener(() => { checkHost(); notify(); @@ -306,28 +300,19 @@ export function createWeatherLayer({ motion = matchMedia?.('(prefers-reduced-motion: reduce)'); motion?.addEventListener?.('change', onVisibility); documentRef?.addEventListener?.('visibilitychange', onVisibility); - if (nextViewer || options.engine) - layer.attachRenderer(nextViewer, options); + if (options.engine) layer.attachRenderer(nextViewer, options); }, - attachRenderer(nextViewer, { engine = null } = {}) { - viewer = nextViewer; + attachRenderer(_viewer, { engine = null } = {}) { sceneEngine = engine; - const options = { viewer, getHost, onChange: notify }; - // The engine's display (render/display.js) when the layer has one; - // else the Cesium renderer, whose SDK is read here: the production - // build's Cesium global loads with the first Cesium globe. - const display = engine - ? (createNativeRendering?.(engine, options) ?? null) + rendering = engine + ? (createNativeRendering(engine, { onChange: notify }) ?? null) : null; - drawsOnEngine = display !== null; - rendering = - display ?? createRendering({ ...options, cesium: cesium ?? Cesium }); checkHost(false); eventTarget?.addEventListener?.( 'gev:map-stack-changed', onMapStackChanged, ); - rendering.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8); + rendering?.setAlpha(opacity === 'light' ? 0.4 : satellite ? 0.7 : 0.8); if (!enabled) return; watchCamera(); if (!manifest) return; @@ -338,7 +323,7 @@ export function createWeatherLayer({ ? manifest.latest : lastShown; if (time) void show(time); - else rendering.setHidden(true); + else rendering?.setHidden(true); }, detachRenderer() { ++generation; @@ -354,19 +339,12 @@ export function createWeatherLayer({ ); rendering?.clear(); rendering = null; - drawsOnEngine = false; hostCollection = null; - imageryHost = null; runNavigation = null; sceneEngine = null; - viewer = null; loading = Boolean(request && !request.signal.aborted); }, attachShellServices(services) { - imageryHost = - typeof services?.imageryHost === 'function' - ? services.imageryHost - : null; checkHost(); runNavigation = typeof services?.runNavigation === 'function' @@ -516,7 +494,7 @@ export function createWeatherLayer({ followLatest = true; rendering?.clear(); if (clock) void clock.refresh(); - if (enabled) void layer.update(viewer); + if (enabled) void layer.update(); } if (infraredChanged && enabled && manifest) { clearTimeout(timer); @@ -528,7 +506,8 @@ export function createWeatherLayer({ params.focus === true && enabled && manifest?.bounds && - runNavigation + runNavigation && + sceneEngine ) { const b = weatherDisplay(manifest).extent ?? @@ -536,21 +515,14 @@ export function createWeatherLayer({ ? { west: 110, south: -25, east: 0, north: 80 } : manifest.bounds); runNavigation(() => - (sceneEngine?.camera ?? viewer.camera).flyTo({ + sceneEngine.camera.flyTo({ // The engine camera takes a plain rectangle in radians. - destination: sceneEngine - ? { - west: geo.math.toRadians(b.west), - south: geo.math.toRadians(b.south), - east: geo.math.toRadians(b.east), - north: geo.math.toRadians(b.north), - } - : (cesium ?? Cesium).Rectangle.fromDegrees( - b.west, - b.south, - b.east, - b.north, - ), + destination: { + west: geo.math.toRadians(b.west), + south: geo.math.toRadians(b.south), + east: geo.math.toRadians(b.east), + north: geo.math.toRadians(b.north), + }, duration: motion?.matches ? 0 : 1.4, }), ); @@ -645,19 +617,16 @@ export function createWeatherLayer({ const shownProblem = problem ? presentWeatherProblem(problem) : null; const shownHost = hostStatus ? t(hostStatus) : null; const b = manifest?.bounds; - const canvas = sceneEngine?.host.canvas ?? viewer?.scene?.canvas; + const canvas = sceneEngine?.host.canvas; const point = canvas && - (sceneEngine?.camera ?? viewer.camera).pickEllipsoid?.( + sceneEngine.camera.pickEllipsoid( geo.cartesian2.create( canvas.clientWidth / 2, canvas.clientHeight / 2, ), - viewer?.scene?.globe?.ellipsoid, ); - const camera = point - ? geo.cartographic.fromCartesian(point, viewer?.scene?.globe?.ellipsoid) - : null; + const camera = point ? geo.cartographic.fromCartesian(point) : null; const lon = camera ? geo.math.toDegrees(camera.longitude) : 0; const lat = camera ? geo.math.toDegrees(camera.latitude) : 0; const outside = diff --git a/src/layers/weather/rasterTiles.js b/src/layers/weather/rasterTiles.js deleted file mode 100644 index cba2b7a0c..000000000 --- a/src/layers/weather/rasterTiles.js +++ /dev/null @@ -1,50 +0,0 @@ -/** 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); - }, - }; -} diff --git a/src/layers/weather/rasterTiles.test.mjs b/src/layers/weather/rasterTiles.test.mjs deleted file mode 100644 index 5efcf11c0..000000000 --- a/src/layers/weather/rasterTiles.test.mjs +++ /dev/null @@ -1,156 +0,0 @@ -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); - } -}); diff --git a/src/layers/weather/render/display.js b/src/layers/weather/render/display.js index 6432e155f..3ec6dbdb6 100644 --- a/src/layers/weather/render/display.js +++ b/src/layers/weather/render/display.js @@ -571,7 +571,6 @@ export function createWeatherDisplay( shell?.clear(); shell = createWeatherShell({ ...shared, - cesium: null, product, height: WEATHER_SHELL_HEIGHTS[product], getHost, diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js deleted file mode 100644 index 629101693..000000000 --- a/src/layers/weather/rendering.js +++ /dev/null @@ -1,764 +0,0 @@ -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 { weatherDisplay, escapeCreditText } from './display.js'; -import { msg } from '../../i18n/index.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; -// How often a staged frame waiting out its quiet interval rechecks readiness. -export const READY_WAKE_MS = 150; - -/** 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 creditText(snapshot) { - const sourceCredit = weatherDisplay(snapshot).credit; - return sourceCredit !== undefined - ? escapeCreditText(sourceCredit) - : 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'; - } - function setCredit(frame) { - const text = creditText(frame.creditSnapshot ?? frame.snapshot); - if (frame.credit?.html === text) return false; - frame.credit = new cesium.Credit(text, false); - return true; - } - /** - * Take the credit of a refreshed manifest of the same product. Each frame - * keeps the snapshot it was staged from, whose time and bounds still match, - * and remembers the refreshed one for its credit, including a frame that - * is still loading and takes its credit when installed. - */ - function updateSnapshot(snapshot) { - let changed = false; - for (const frame of new Set([current, incoming, ...retiring])) { - if (!frame || frame.product !== snapshot.product) continue; - frame.creditSnapshot = snapshot; - if (frame.credit && setCredit(frame)) changed = true; - } - if (changed) viewer.scene.requestRender(); - } - - 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); - clearTimeout(frame.wake); - 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, - updateSnapshot, - 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(msg('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', creditSnapshot } = {}, - ) { - 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 - ) { - updateSnapshot(creditSnapshot ?? snapshot); - 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, - creditSnapshot, - 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); - clearTimeout(frame.wake); - frame.wake = null; - 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 = msg( - '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, - ); - setCredit(frame); - const credit = frame.credit; - 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, - }); - // Cesium reads a provider's credit every frame; a refreshed manifest - // can replace it without restaging the imagery. - Object.defineProperty(provider, 'credit', { - configurable: true, - enumerable: true, - get: () => frame.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 = msg('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; - // Waiting out the quiet interval needs another readiness check - // later, not a render every frame: wake on a timer so the render - // governor's frame cap still holds while a frame is staged. - if ( - incoming === frame && - frame.pending === 0 && - frame.loaded > 0 && - !frame.wake - ) - frame.wake = setTimeout(() => { - frame.wake = null; - if (!frame.closed && incoming === frame) - viewer.scene.requestRender(); - }, READY_WAKE_MS); - }); - 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; - // The last refreshed manifest, so a frame shown after it keeps its credit. - let refreshed = 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(); - }; - // The restaged frame takes the newest refreshed credit; a refresh while - // it loads reaches it through the globe's own updateSnapshot. - target - .setFrame(frame.snapshot, frame.time, { - infrared: frame.infrared, - ...(target === globe ? { creditSnapshot: shown?.latest } : {}), - }) - .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 before = refreshed; - const ok = await target.setFrame(snapshot, time, options); - if (ok) { - // Keep a refresh that arrived while this frame loaded. - const latest = - refreshed !== before && refreshed?.product === snapshot.product - ? refreshed - : undefined; - shown = { - snapshot, - time, - infrared: options.infrared ?? 'filtered', - ...(latest ? { latest } : {}), - }; - } - return ok; - }, - // The shown frame keeps its staged snapshot for a restage on another - // host; only the refreshed credit is remembered and applied. - updateSnapshot(snapshot) { - refreshed = snapshot; - if (shown?.snapshot.product === snapshot.product) - shown = { ...shown, latest: snapshot }; - globe.updateSnapshot(snapshot); - }, - setHidden(value) { - hidden = Boolean(value); - if (hidden) restaging = null; - renderer()?.setHidden(hidden); - }, - setAlpha(value) { - alpha = value; - renderer()?.setAlpha(value); - }, - clear() { - restaging = null; - shown = null; - refreshed = 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; - }, - }; -} diff --git a/src/layers/weather/shellFixture.mjs b/src/layers/weather/shellFixture.mjs deleted file mode 100644 index 18e5de788..000000000 --- a/src/layers/weather/shellFixture.mjs +++ /dev/null @@ -1,156 +0,0 @@ -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(); - } -} diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index bff4705ae..65abda303 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -14,64 +14,13 @@ export const WEATHER_SHELL_HEIGHTS = Object.freeze({ }); // 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. +// canvases); nothing older survives them. The engine holds the shown canvases +// through its raised layer 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 && @@ -79,255 +28,6 @@ const sameEdges = (a, b) => 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 }) { - // Cesium's limit once its context exists; a Navara shell has no SDK. - 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 @@ -393,10 +93,10 @@ export function detailWindow(footprint, bounds, previous = null) { /** 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. */ + * that area, so the two images share one mesh and never blend. The surfaces + * are the engine's (`createSurface`, render/display.js), as are the render + * requests, the view footprint and the camera's move-end event. */ export function createWeatherShell({ - viewer, - cesium, product, height = WEATHER_SHELL_HEIGHTS[product], getHost = () => ({ collection: null, kind: 'tileset' }), @@ -407,16 +107,15 @@ export function createWeatherShell({ decodeImage, createCanvas = () => document.createElement('canvas'), cacheBytes = WEATHER_SHELL_CACHE_BYTES, - createSurface = createShellSurface, - requestRender = () => viewer.scene.requestRender(), - viewRectangle = () => - viewer.camera?.computeViewRectangle?.(viewer.scene.globe?.ellipsoid), - moveEnd = viewer?.camera?.moveEnd, + createSurface, + requestRender, + viewRectangle, + moveEnd, }) { if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product)) throw new TypeError('Unknown weather product'); - const size = fitTexture(cesium, WEATHER_IMAGE_SIZES[product]); - const detailSize = fitTexture(cesium, WEATHER_DETAIL_SIZE); + const size = { ...WEATHER_IMAGE_SIZES[product] }; + const detailSize = { ...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. @@ -678,7 +377,7 @@ export function createWeatherShell({ 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. + // The surface keeps drawing the previous image until this one draws. surface.setImage(image); commit(frame); return; @@ -687,18 +386,14 @@ export function createWeatherShell({ // previous one until the new geometry and texture are drawable. const { west, south, east, north } = frame.snapshot.bounds; const next = createSurface({ - viewer, - cesium, height, - // A Navara surface reads the four radians; Cesium gets its Rectangle. - rectangle: cesium - ? cesium.Rectangle.fromDegrees(west, south, east, north) - : { - west: geo.math.toRadians(west), - south: geo.math.toRadians(south), - east: geo.math.toRadians(east), - north: geo.math.toRadians(north), - }, + // The extent in radians. + rectangle: { + west: geo.math.toRadians(west), + south: geo.math.toRadians(south), + east: geo.math.toRadians(east), + north: geo.math.toRadians(north), + }, onSettled: () => commit(frame), }); next.bounds = frame.bounds; diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index e56a57c5f..2a95bc1c8 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -1,21 +1,19 @@ -import test from 'node:test'; +// The weather shell on the fake and headless Cesium engines: each surface is the display's raised +// imagery layer (render/display.js), whose staged image and detail draw on +// the next microtask. A surface's settle waits for `render()`, as a drawn +// frame waits for the engine to draw it. import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { imageryDisplayTests } from '../../testSupport/imageryDisplayEngines.mjs'; +const { test, createEngine } = imageryDisplayTests(); import { - createShellSurface, createWeatherShell, detailWindow, - orderWeatherShells, - shellLift, WEATHER_SHELL_CACHE_BYTES, WEATHER_SHELL_HEIGHTS, } from './shellRendering.js'; +import { createRaisedWeatherSurface } from './render/display.js'; import { NO_IMAGERY_HOST } from './imageryHost.js'; -import { - createShellCesium, - createShellScene, - renderShells, -} from './shellFixture.mjs'; const times = [ '2026-09-15T20:00:00.000Z', @@ -59,24 +57,48 @@ const response = () => ({ headers: new Headers(), arrayBuffer: async () => new ArrayBuffer(1), }); -// The detail window uniform; NONE is the empty window. -const NONE = { x: 0, y: 0, z: -1, w: -1 }; -const windowOf = (primitive) => { - const { x, y, z, w } = primitive.appearance.material.uniforms.window; - return { x, y, z, w }; -}; +const radians = ([west, south, east, north]) => ({ + west: geo.math.toRadians(west), + south: geo.math.toRadians(south), + east: geo.math.toRadians(east), + north: geo.math.toRadians(north), +}); +const box = (west, south, east, north) => ({ west, south, east, north }); +/** A detail rectangle (degrees) equals `expected`, within radian round trips. */ +function assertBox(actual, expected, message) { + assert.ok(actual, message ?? 'a detail is drawn'); + for (const edge of ['west', 'south', 'east', 'north']) + assert.ok( + Math.abs(actual[edge] - expected[edge]) < 1e-9, + `${message ?? 'detail'}: ${edge} ${actual[edge]} ≠ ${expected[edge]}`, + ); +} -function harness({ product = 'radar', camera, ...options } = {}) { - const cesium = options.cesium ?? createShellCesium(); - const scene = createShellScene(); +/** + * The shell over the fake and headless Cesium engines. `view` is the camera's footprint in + * degrees (none: no footprint); `look(...)` moves it and ends the move. + */ +function harness({ product = 'radar', view = null, ...options } = {}) { + const engine = createEngine(); + let footprint = view; + engine.fake.script('viewRectangle', () => + footprint ? radians(footprint) : null, + ); const fetches = []; const decoded = []; + const settles = []; let host = { collection: null, kind: 'tileset' }; const shell = createWeatherShell({ - viewer: { scene, camera }, - cesium, product, getHost: () => host, + requestRender: () => engine.render.request('weather'), + viewRectangle: () => engine.camera.viewRectangle(), + moveEnd: engine.camera.moveEnd, + createSurface: (frame) => + createRaisedWeatherSurface(engine, product, { + ...frame, + onSettled: () => settles.push(frame.onSettled), + }), fetchImpl: async (url, init) => { fetches.push({ url, signal: init.signal }); return response(); @@ -99,24 +121,38 @@ function harness({ product = 'radar', camera, ...options } = {}) { createCanvas: canvas, ...options, }); + const ids = () => engine.fake.imageryLayers(); + const layer = (id = ids().at(-1)) => + id ? engine.fake.imageryLayer(id) : undefined; return { shell, - cesium, - scene, + engine, fetches, decoded, setHost(value) { host = value; }, - render: (count) => renderShells(cesium, scene, count), - primitives: () => scene.primitives.items, - material: () => scene.primitives.items.at(-1)?.appearance.material, + look(...edges) { + footprint = edges; + engine.camera.moveEnd.raiseEvent(); + }, + listeners: () => engine.camera.moveEnd.numberOfListeners, + /** Draws what is staged and settles the surfaces that have drawn. */ + async render() { + await flush(); + for (const settle of settles.splice(0)) settle(); + await flush(); + }, + ids, + layer, + image: (id) => layer(id)?.source.image, + detail: (id) => layer(id)?.detail ?? null, }; } async function show(h, time, options) { const pending = h.shell.setFrame(snapshot(h.shell.product), time, options); await flush(); - h.render(); + await h.render(); return pending; } @@ -131,337 +167,20 @@ test('shell heights stack every product with lightning highest', () => { assert.equal(WEATHER_SHELL_CACHE_BYTES, 128 * 1024 * 1024); }); -test('surface builds a flat raised rectangle drawn in primitive order without depth writes', () => { - const cesium = createShellCesium(); - const scene = createShellScene(); - const rectangle = Cesium.Rectangle.fromDegrees(-130, 20, -60, 55); - const surface = createShellSurface({ - viewer: { scene }, - cesium, - rectangle, - height: 6_200, - }); - const template = cesium.templates.get('WeatherFrame'); - assert.ok(!(template instanceof cesium.Material), 'plain template'); - assert.deepEqual(template.fabric.uniforms, { - image: Cesium.Material.DefaultImageId, - detail: Cesium.Material.DefaultImageId, - alpha: 1, - window: { type: 'vec4', x: 0, y: 0, z: -1, w: -1 }, - }); - const { source } = template.fabric; - assert.match( - source, - /material\.alpha = c\.a \* alpha \* step\(1\.5, float\(imageDimensions\.x\)\);/, - 'nothing draws before the full-extent image', - ); - assert.match( - source, - /float useDetail = inside \* step\(1\.5, float\(detailDimensions\.x\)\);/, - 'the detail image is sampled only once it has arrived', - ); - assert.match( - source, - /all\(greaterThanEqual\(st, window\.xy\)\) && all\(lessThanEqual\(st, window\.zw\)\)/, - ); - assert.match( - source, - /mix\(coarse, texture\(detail, clamp\(dst, 0\.0, 1\.0\)\), useDetail\)/, - ); - assert.match(source, /material\.diffuse = czm_gammaCorrect\(c\.rgb\);/); - const [primitive] = scene.primitives.items; - const appearance = primitive.appearance; - assert.equal(appearance.material.options.translucent, false); - assert.deepEqual( - { ...appearance.options, material: undefined }, - { - aboveGround: true, - flat: true, - translucent: false, - material: undefined, - }, - ); - assert.deepEqual(appearance.getRenderState(), { - depthTest: { enabled: true }, - depthMask: false, - blending: Cesium.BlendingState.ALPHA_BLEND, - }); - const geometry = primitive.options.geometryInstances.geometry.options; - assert.equal(geometry.rectangle, rectangle); - assert.equal(geometry.height, 6_200); - assert.equal(geometry.granularity, Cesium.Math.toRadians(0.5)); - assert.equal( - geometry.vertexFormat, - Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT, - ); - assert.equal(primitive.options.asynchronous, true); - assert.equal(primitive.options.allowPicking, false); - const lower = createShellSurface({ - viewer: { scene }, - cesium, - rectangle, - height: 5_000, - }); - assert.equal(cesium.templates.size, 1, 'the template registers once'); - assert.deepEqual( - scene.primitives.items.map( - (item) => item.options.geometryInstances.geometry.options.height, - ), - [5_000, 6_200], - ); - lower.destroy(); - surface.destroy(); -}); - -test('a surface applies a new detail window only once its image has drawn; the same window swaps only the image', () => { - const cesium = createShellCesium(); - const scene = createShellScene(); - const surface = createShellSurface({ - viewer: { scene }, - cesium, - rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), - height: 6_200, - }); - const [primitive] = scene.primitives.items; - const { uniforms } = primitive.appearance.material; - const render = (count) => renderShells(cesium, scene, count); - const image = () => ({ width: 4096, height: 2048 }); - surface.setImage(image()); - render(); - assert.deepEqual(surface.getDiagnostics().detail, { - uploaded: false, - window: null, - }); - const a = { west: 0.25, south: 0.5, east: 0.5, north: 0.75 }; - const first = image(); - surface.setDetail(first, a); - assert.equal(uniforms.detail, first); - assert.deepEqual(windowOf(primitive), NONE, 'the full-extent image covers'); - assert.equal(scene.postRender.size, 1, 'renders until the detail has drawn'); - render(2); - assert.deepEqual(windowOf(primitive), NONE); - render(1); - assert.deepEqual(windowOf(primitive), { x: 0.25, y: 0.5, z: 0.5, w: 0.75 }); - assert.ok(uniforms.window instanceof Cesium.Cartesian4); - assert.deepEqual(surface.getDiagnostics().detail, { - uploaded: true, - window: a, - }); - assert.equal(scene.postRender.size, 0); - - // A time change in the same window: Cesium keeps drawing the previous - // texture until the new one is uploaded, so the window stays. - const applied = uniforms.window; - const second = image(); - surface.setDetail(second, { ...a }); - assert.equal(uniforms.detail, second); - assert.equal(uniforms.window, applied, 'only the image is written'); - assert.equal(surface.getDiagnostics().detail.uploaded, false); - render(); - assert.equal(surface.getDiagnostics().detail.uploaded, true); - assert.equal(uniforms.window, applied); - const renders = scene.renders; - surface.setDetail(second, { ...a }); - assert.equal(scene.renders, renders, 'an unchanged detail costs nothing'); - assert.equal(scene.postRender.size, 0); - - // A different window empties at once and applies once its image has drawn. - const b = { west: 0.5, south: 0.25, east: 0.75, north: 0.5 }; - surface.setDetail(image(), b); - assert.deepEqual(windowOf(primitive), NONE); - render(2); - assert.deepEqual(windowOf(primitive), NONE); - render(1); - assert.deepEqual(windowOf(primitive), { x: 0.5, y: 0.25, z: 0.75, w: 0.5 }); - - surface.setDetail(null); - assert.equal(uniforms.detail, Cesium.Material.DefaultImageId); - assert.deepEqual(windowOf(primitive), NONE); - assert.deepEqual(surface.getDiagnostics().detail, { - uploaded: false, - window: null, - }); - render(); - assert.deepEqual( - { ...uniforms.detailDimensions }, - { type: 'ivec3', x: 1, y: 1 }, - ); - // Counted renders alone are not enough: the bound texture must be this image. - surface.setDetail(image(), a); - for (let i = 0; i < 4; i++) scene.postRender.emit(); - assert.deepEqual(windowOf(primitive), NONE, 'still the 1×1 default'); - render(1); - assert.deepEqual(windowOf(primitive), { x: 0.25, y: 0.5, z: 0.5, w: 0.75 }); - surface.destroy(); - surface.setDetail(image(), b); - assert.deepEqual(windowOf(primitive), { x: 0.25, y: 0.5, z: 0.5, w: 0.75 }); - assert.equal(scene.postRender.size, 0); -}); - -test('shells rise with the camera over coarse 3D tiles, in steps, up to 60 km', () => { - assert.equal(shellLift(undefined), 0); - assert.equal(shellLift(1_200), 0, 'city views keep the shell heights'); - assert.equal(shellLift(3_000_000), 12_000); - assert.equal(shellLift(8_000_000), 32_000); - assert.equal(shellLift(18_500_000), 60_000); - const camera = { positionCartographic: { height: 1_200 } }; - const scene = { - primitives: new Cesium.PrimitiveCollection(), - preRender: new Cesium.Event(), - postRender: new Cesium.Event(), - camera, - requestRender() {}, - }; - const surface = createShellSurface({ - viewer: { scene }, - cesium: Cesium, - rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), - height: 5_800, - }); - const primitive = scene.primitives.get(0); - const identity = primitive.modelMatrix; - scene.preRender.raiseEvent(); - assert.equal(primitive.modelMatrix, identity, 'no lift, no new matrix'); - camera.positionCartographic.height = 8_000_000; - scene.preRender.raiseEvent(); - const radius = Cesium.Ellipsoid.WGS84.maximumRadius; - assert.ok( - Math.abs(primitive.modelMatrix[0] * radius - (radius + 32_000)) < 1e-3, - ); - const lifted = primitive.modelMatrix; - camera.positionCartographic.height = 8_010_000; - scene.preRender.raiseEvent(); - assert.equal(primitive.modelMatrix, lifted, 'the same step keeps the matrix'); - surface.destroy(); - assert.equal(scene.preRender.numberOfListeners, 0); -}); - -test('real Cesium builds the two-texture material, the opaque-pass appearance and the rectangle primitive', () => { - const scene = { - primitives: new Cesium.PrimitiveCollection(), - postRender: new Cesium.Event(), - requestRender() {}, - }; - const surface = createShellSurface({ - viewer: { scene }, - cesium: Cesium, - rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), - height: 6_600, - }); - const cached = Cesium.Material._materialCache.getMaterial('WeatherFrame'); - assert.ok(cached && !(cached instanceof Cesium.Material)); - const primitive = scene.primitives.get(0); - const material = primitive.appearance.material; - assert.ok(primitive instanceof Cesium.Primitive); - assert.equal(material.type, 'WeatherFrame'); - assert.equal(material.isTranslucent(), false); - assert.equal(primitive.appearance.isTranslucent(), false, 'opaque pass'); - assert.equal(primitive.appearance.getRenderState().depthMask, false); - assert.equal( - Cesium.Appearance.prototype.getRenderState.call(primitive.appearance) - .depthMask, - true, - 'the default opaque render state would write depth', - ); - assert.deepEqual( - [material.uniforms.imageDimensions.x, material.uniforms.detailDimensions.x], - [1, 1], - 'Cesium tracks both bound texture sizes', - ); - const shader = material.shaderSource; - for (const declaration of [ - /uniform sampler2D image_\d+;/, - /uniform sampler2D detail_\d+;/, - /uniform float alpha_\d+;/, - /uniform vec4 window_\d+;/, - /uniform ivec3 imageDimensions_\d+;/, - /uniform ivec3 detailDimensions_\d+;/, - ]) - assert.match(shader, declaration); - assert.match(shader, /texture\(image_\d+, st\)/); - assert.match(shader, /texture\(detail_\d+, clamp\(dst, 0\.0, 1\.0\)\)/); - assert.match(shader, /lessThanEqual\(st, window_\d+\.zw\)/); - assert.match(shader, /step\(1\.5, float\(detailDimensions_\d+\.x\)\)/); - assert.match( - shader, - /material\.alpha = c\.a \* alpha_\d+ \* step\(1\.5, float\(imageDimensions_\d+\.x\)\);/, - 'the uniform is renamed, the material field is not', - ); - assert.match(shader, /float useDetail = inside \* step/, 'locals keep names'); - const detail = { width: 4096, height: 2048 }; - surface.setDetail(detail, { west: 0.1, south: 0.2, east: 0.3, north: 0.4 }); - assert.equal(material.uniforms.detail, detail); - assert.deepEqual( - [material.uniforms.window.z, material.uniforms.window.w], - [-1, -1], - 'no window before the image has drawn', - ); - surface.setDetail(null); - assert.equal(material.uniforms.detail, Cesium.Material.DefaultImageId); - surface.setAlpha(0.4); - assert.equal(material.uniforms.alpha, 0.4); - surface.destroy(); - assert.equal(scene.primitives.length, 0); - assert.equal(primitive.isDestroyed(), true); - assert.equal(material.isDestroyed(), true); - assert.equal(scene.postRender.numberOfListeners, 0); -}); - -test('shells draw first, in height order, regardless of add order', () => { - const scene = createShellScene(); - const other = { name: 'other' }; - scene.primitives.add(other); - const add = (name, height) => { - const primitive = { name }; - scene.primitives.add(primitive); - orderWeatherShells(scene.primitives, primitive, height); - return primitive; - }; - add('lightning', 6_600); - add('radar', 6_200); - add('wind', 5_000); - const later = { name: 'later' }; - scene.primitives.add(later); - add('regional', 5_800); - assert.deepEqual( - scene.primitives.items.map(({ name }) => name), - ['wind', 'regional', 'radar', 'lightning', 'other', 'later'], - ); - const primitives = new Cesium.PrimitiveCollection({ - destroyPrimitives: false, - }); - const real = (name) => ({ name, update() {}, isDestroyed: () => false }); - const tileset = primitives.add(real('tileset')); - const lightning = primitives.add(real('lightning')); - orderWeatherShells(primitives, lightning, 6_600); - const radar = primitives.add(real('radar')); - orderWeatherShells(primitives, radar, 6_200); - assert.deepEqual( - [0, 1, 2].map((i) => primitives.get(i).name), - ['radar', 'lightning', 'tileset'], - 'real Cesium collection order', - ); - assert.equal(tileset.name, 'tileset'); -}); - -test('first frame stages invisibly, requests renders until drawable and then commits', async () => { +test('first frame stages invisibly, then commits once drawn', async () => { const h = harness(); const pending = h.shell.setFrame(snapshot(), times[0]); assert.equal(h.shell.getDiagnostics().loading, true); await flush(); - const material = h.material(); - assert.equal(h.primitives().length, 1); - assert.equal(material.uniforms.alpha, 0, 'staging surface is invisible'); - assert.equal(material.uniforms.image.width, 4096); - assert.equal(h.scene.postRender.size, 1, 'render loop while not drawable'); - const before = h.scene.renders; - h.scene.postRender.emit(); - assert.ok(h.scene.renders > before, 'each render asks for another'); + assert.equal(h.ids().length, 1); + const staged = h.layer(); + assert.equal(staged.alpha, 0, 'staging surface is invisible'); + assert.equal(staged.raise, 6_200); + assert.equal(h.image().width, 4096); assert.equal(h.shell.getDiagnostics().time, null); - h.render(); + await h.render(); assert.equal(await pending, true); - assert.equal(material.uniforms.alpha, 0.7); - assert.equal(h.scene.postRender.size, 0, 'render loop ends'); + assert.equal(h.layer().alpha, 0.7); const diagnostics = h.shell.getDiagnostics(); assert.equal(diagnostics.host, 'shell'); assert.equal(diagnostics.height, 6_200); @@ -484,7 +203,7 @@ test('first frame stages invisibly, requests renders until drawable and then com enabled: false, }, }); - assert.equal(h.fetches.length, 1, 'no camera, no detail window'); + assert.equal(h.fetches.length, 1, 'no footprint, no detail window'); assert.equal( new URL(h.fetches[0].url, 'https://example.test').search, `?product=radar&time=${encodeURIComponent(times[0])}`, @@ -492,7 +211,7 @@ test('first frame stages invisibly, requests renders until drawable and then com h.shell.clear(); }); -test('a new frame keeps the previous image until decoded, then swaps the uniform', async () => { +test('a new frame keeps the previous image until decoded, then swaps it on the same surface', async () => { const decode = deferred(); let first = true; const h = harness({ @@ -502,11 +221,11 @@ test('a new frame keeps the previous image until decoded, then swaps the uniform : decode.promise, }); assert.equal(await show(h, times[0]), true); - const material = h.material(); - const previous = material.uniforms.image; + const [id] = h.ids(); + const previous = h.image(); const pending = h.shell.setFrame(snapshot(), times[1]); await flush(); - assert.equal(material.uniforms.image, previous, 'previous image stays'); + assert.equal(h.image(), previous, 'previous image stays'); assert.equal(h.shell.getDiagnostics().time, times[0]); assert.equal(h.shell.getDiagnostics().loading, true); let closed = false; @@ -519,13 +238,11 @@ test('a new frame keeps the previous image until decoded, then swaps the uniform }); assert.equal(await pending, true); assert.equal(closed, true, 'decoded image is released'); - assert.notEqual(material.uniforms.image, previous); - assert.equal(material.uniforms.image.source.width, 4096, 'canvas copy'); - assert.equal(h.primitives().length, 1, 'same extent reuses the surface'); + await flush(); + assert.notEqual(h.image(), previous); + assert.equal(h.image().source.width, 4096, 'canvas copy'); + assert.deepEqual(h.ids(), [id], 'same extent reuses the surface'); assert.equal(h.shell.getDiagnostics().time, times[1]); - assert.equal(h.scene.postRender.size, 1, 'renders until the upload'); - h.render(); - assert.equal(h.scene.postRender.size, 0); h.shell.clear(); }); @@ -549,13 +266,13 @@ for (const outcome of ['fetch', 'decode', 'dimensions', 'timeout']) { }, }); assert.equal(await show(h, times[0]), true); - const image = h.material().uniforms.image; + const image = h.image(); fail = true; const pending = h.shell.setFrame(snapshot(), times[1]); await flush(); if (outcome === 'timeout') t.mock.timers.tick(25_000); assert.equal(await pending, false); - assert.equal(h.material().uniforms.image, image); + assert.equal(h.image(), image); assert.equal(h.shell.getDiagnostics().time, times[0]); assert.equal( h.shell.getDiagnostics().error, @@ -568,21 +285,19 @@ for (const outcome of ['fetch', 'decode', 'dimensions', 'timeout']) { }); } -test('alpha writes the uniform and hidden hides without losing the frame', async () => { +test('alpha restyles the surface and hidden hides without losing the frame', async () => { const h = harness(); h.shell.setAlpha(0.4); assert.equal(await show(h, times[0]), true); - const material = h.material(); - assert.equal(material.uniforms.alpha, 0.4); - const renders = h.scene.renders; + assert.equal(h.layer().alpha, 0.4); + const renders = h.engine.fake.renderRequests; h.shell.setAlpha(0.8); - assert.equal(material.uniforms.alpha, 0.8); - assert.ok(h.scene.renders > renders); - const primitive = h.primitives()[0]; + assert.equal(h.layer().alpha, 0.8); + assert.ok(h.engine.fake.renderRequests > renders); const pending = h.shell.setFrame(snapshot(), times[1]); h.shell.setHidden(true); assert.equal(await pending, false, 'hiding cancels a stage'); - assert.equal(primitive.show, false); + assert.equal(h.layer().shown, false); assert.equal(h.shell.getDiagnostics().time, null); assert.equal(h.shell.getDiagnostics().hidden, true); assert.equal( @@ -591,25 +306,22 @@ test('alpha writes the uniform and hidden hides without losing the frame', async 'hidden frames do not prefetch', ); h.shell.setHidden(false); - assert.equal(primitive.show, true); + assert.equal(h.layer().shown, true); assert.equal(h.shell.getDiagnostics().time, times[0]); h.shell.clear(); }); -test('clear destroys the primitive, material, listeners, timers and decoded images', async (t) => { +test('clear destroys the surface, listeners, timers and decoded images', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = harness(); + const h = harness({ view: [-100, 35, -98, 36] }); assert.equal(await show(h, times[0]), true); - const [primitive] = h.primitives(); - const material = primitive.appearance.material; + assert.equal(h.listeners(), 1); const pending = h.shell.setFrame(snapshot(), times[1]); const signal = h.fetches.at(-1)?.signal; h.shell.clear(); assert.equal(await pending, false); - assert.equal(h.primitives().length, 0); - assert.equal(primitive.destroyed, true); - assert.equal(material.destroyed, true, 'the material is destroyed too'); - assert.equal(h.scene.postRender.size, 0); + assert.deepEqual(h.ids(), [], 'the surface is destroyed'); + assert.equal(h.listeners(), 0); assert.equal(signal?.aborted ?? true, true); assert.deepEqual(h.shell.getDiagnostics().cache, { mosaics: 0, @@ -618,28 +330,27 @@ test('clear destroys the primitive, material, listeners, timers and decoded imag }); assert.equal(h.shell.getDiagnostics().time, null); t.mock.timers.tick(30_000); - assert.equal(h.primitives().length, 0); + assert.deepEqual(h.ids(), []); }); -test('a new extent stages a second surface and retires the first only once drawable', async () => { +test('a new extent stages a second surface and retires the first only once drawn', async () => { const h = harness(); assert.equal(await show(h, times[0]), true); - const [first] = h.primitives(); + const [first] = h.ids(); const pending = h.shell.setFrame( snapshot('radar', { bounds: { ...bounds, south: 21 } }), times[0], ); await flush(); - assert.equal(h.primitives().length, 2); - const second = h.primitives()[1]; - assert.equal(second.appearance.material.uniforms.alpha, 0); + assert.equal(h.ids().length, 2); + const second = h.ids()[1]; + assert.equal(h.layer(second).alpha, 0); assert.equal(h.shell.getDiagnostics().imageryCount, 2); - h.render(); + await h.render(); assert.equal(await pending, true); - assert.deepEqual(h.primitives(), [second]); - assert.equal(first.destroyed, true); - assert.equal(first.appearance.material.destroyed, true); - assert.equal(second.appearance.material.uniforms.alpha, 0.7); + assert.deepEqual(h.ids(), [second]); + assert.equal(h.layer(first), undefined, 'the first is destroyed'); + assert.equal(h.layer(second).alpha, 0.7); h.shell.clear(); }); @@ -656,7 +367,7 @@ test('decoded frames are a byte-bounded LRU that never evicts the displayed fram for (const time of all.slice(0, 6)) { const pending = clouds.shell.setFrame(sample, time); await flush(); - clouds.render(); + await clouds.render(); assert.equal(await pending, true); } assert.deepEqual(clouds.shell.getDiagnostics().cache, { @@ -666,7 +377,7 @@ test('decoded frames are a byte-bounded LRU that never evicts the displayed fram }); const pending = clouds.shell.setFrame(sample, all[6]); await flush(); - clouds.render(); + await clouds.render(); assert.equal(await pending, true); assert.equal(clouds.shell.getDiagnostics().cache.mosaics, 6); const fetches = clouds.fetches.length; @@ -710,7 +421,7 @@ test('prefetch warms a decoded frame without staging and cancels cleanly', async const warm = h.shell.prefetch(snapshot(), times[1]); assert.equal(h.shell.getDiagnostics().cache.prefetching, true); assert.equal(await warm, true); - assert.equal(h.primitives().length, 1); + assert.equal(h.ids().length, 1); assert.equal(h.shell.getDiagnostics().time, times[0]); assert.equal( await h.shell.prefetch(snapshot(), times[1]), @@ -753,18 +464,19 @@ test('infrared frames get the display transfer; radar and lightning keep their p const h = harness({ product }); const pending = h.shell.setFrame(snapshot(product), times[0]); await flush(); - const image = h.material().uniforms.image; + const image = h.image(); assert.equal(image.source, h.decoded[0], 'drawn into a canvas'); assert.equal(h.decoded[0].closed, true); assert.equal(image.pixels !== undefined, transfer, product); if (transfer) assert.deepEqual([...image.pixels], [194, 0, 0, 98], 'clouds only'); - h.render(); + await h.render(); assert.equal(await pending, true); assert.equal( h.shell.getDiagnostics().height, WEATHER_SHELL_HEIGHTS[product], ); + assert.equal(h.layer().raise, WEATHER_SHELL_HEIGHTS[product]); h.shell.clear(); } }); @@ -772,33 +484,29 @@ test('infrared frames get the display transfer; radar and lightning keep their p test('an infrared mode change restages the same time with its own cached canvas', async () => { const h = harness({ product: 'clouds' }); assert.equal(await show(h, times[0]), true); - const filtered = h.material().uniforms.image; + const filtered = h.image(); assert.equal(await show(h, times[0]), true, 'same frame is a no-op'); assert.equal(h.fetches.length, 1); assert.equal(await show(h, times[0], { infrared: 'full' }), true); - const full = h.material().uniforms.image; + const full = h.image(); assert.notEqual(full, filtered); assert.deepEqual([...full.pixels], [194, 0, 0, 200]); assert.equal(h.shell.getDiagnostics().infrared, 'full'); assert.equal(await show(h, times[0], { infrared: 'filtered' }), true); - assert.equal(h.material().uniforms.image, filtered, 'cache hit'); + assert.equal(h.image(), filtered, 'cache hit'); assert.equal(h.fetches.length, 2); h.shell.clear(); }); -test('image size follows the product maximum and halves for small texture limits', async () => { - for (const [product, limit, size, query] of [ - ['radar', 0, [4096, 2048], null], - ['clouds-regional', 16_384, [4096, 2048], null], - ['radar', 2048, [2048, 1024], '2048x1024'], - ['lightning', 0, [4096, 2048], null], - ['lightning', 4096, [4096, 2048], null], - ['clouds', 0, [2048, 1024], null], - ['clouds', 1024, [1024, 512], '1024x512'], +test('image size follows the product maximum', async () => { + for (const [product, size] of [ + ['radar', [4096, 2048]], + ['clouds-regional', [4096, 2048]], + ['lightning', [4096, 2048]], + ['clouds', [2048, 1024]], ]) { const h = harness({ product, - cesium: createShellCesium({ maximumTextureSize: limit }), decodeImage: async () => ({ width: size[0], height: size[1], @@ -809,14 +517,11 @@ test('image size follows the product maximum and halves for small texture limits width: size[0], height: size[1], }); - const pending = h.shell.setFrame(snapshot(product), times[0]); - await flush(); - h.render(); - assert.equal(await pending, true); + assert.equal(await show(h, times[0]), true); const url = new URL(h.fetches[0].url, 'https://example.test'); assert.equal(url.pathname, '/api/weather/image'); assert.equal(url.searchParams.get('product'), product); - assert.equal(url.searchParams.get('size'), query); + assert.equal(url.searchParams.get('size'), null); h.shell.clear(); } }); @@ -824,17 +529,16 @@ test('image size follows the product maximum and halves for small texture limits test('a host without imagery hides the shell, keeps the frame and refuses work', async () => { const h = harness(); assert.equal(await show(h, times[0]), true); - const [primitive] = h.primitives(); h.setHost({ collection: null, kind: 'none' }); assert.equal(h.shell.rehome(), true); - assert.equal(primitive.show, false); + assert.equal(h.layer().shown, false); assert.equal(h.shell.getDiagnostics().time, times[0]); assert.equal(h.shell.getDiagnostics().error, NO_IMAGERY_HOST); assert.equal(await h.shell.setFrame(snapshot(), times[1]), false); assert.equal(await h.shell.prefetch(snapshot(), times[1]), false); h.setHost({ collection: null, kind: 'tileset' }); h.shell.rehome(); - assert.equal(primitive.show, true); + assert.equal(h.layer().shown, true); assert.equal(h.shell.getDiagnostics().error, null); h.shell.clear(); }); @@ -854,7 +558,7 @@ test('an unowned restage is joined by the next request for the same frame', asyn assert.equal(await restage, false, 'an owned stage is not joined'); assert.equal(await joined, false); await flush(); - h.render(); + await h.render(); assert.equal(await other, true); const cancelled = h.shell.setFrame(snapshot(), times[1]); const abort = new AbortController(); @@ -869,41 +573,6 @@ test('an unowned restage is joined by the next request for the same frame', asyn }); const footprint = (west, south, east, north) => ({ west, south, east, north }); -const box = (west, south, east, north) => ({ west, south, east, north }); - -test('hidden frames do not count towards a pending detail window', () => { - const cesium = createShellCesium(); - const scene = createShellScene(); - const surface = createShellSurface({ - viewer: { scene }, - cesium, - rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), - height: 6_200, - }); - const [primitive] = scene.primitives.items; - const render = (count) => renderShells(cesium, scene, count); - const image = () => ({ width: 4096, height: 2048 }); - surface.setImage(image()); - const a = { west: 0.25, south: 0.5, east: 0.5, north: 0.75 }; - surface.setDetail(image(), a); - render(); - assert.deepEqual(windowOf(primitive), { x: 0.25, y: 0.5, z: 0.5, w: 0.75 }); - // A different window with a same-sized image: the dimensions alone cannot - // tell the textures apart, so only shown frames may count. - const b = { west: 0.5, south: 0.25, east: 0.75, north: 0.5 }; - surface.setDetail(image(), b); - assert.deepEqual(windowOf(primitive), NONE); - for (let i = 0; i < 3; i++) { - surface.setShow(false); - scene.postRender.emit(); - surface.setShow(true); - } - assert.deepEqual(windowOf(primitive), NONE, 'hidden frames counted nothing'); - render(2); - assert.deepEqual(windowOf(primitive), NONE); - render(1); - assert.deepEqual(windowOf(primitive), { x: 0.5, y: 0.25, z: 0.75, w: 0.5 }); -}); test('detail windows are sized from the footprint, snapped to 0.5° and clamped inside the product', () => { // max(2 × the longitude span, 6°) wide, half as tall. @@ -1023,67 +692,35 @@ test('detail windows hold while the view stays in their inner half and the span ); }); -function camera(west, south, east, north) { - const listeners = new Set(); - let rectangle = Cesium.Rectangle.fromDegrees(west, south, east, north); - return { - moveEnd: { - addEventListener(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - get size() { - return listeners.size; - }, - }, - computeViewRectangle: () => rectangle, - look(...edges) { - rectangle = Cesium.Rectangle.fromDegrees(...edges); - for (const listener of [...listeners]) listener(); - }, - }; -} -// A window in degrees, in the full-extent image's texture coordinates. -const windowFor = (w) => ({ - x: (w.west - bounds.west) / (bounds.east - bounds.west), - y: (w.south - bounds.south) / (bounds.north - bounds.south), - z: (w.east - bounds.west) / (bounds.east - bounds.west), - w: (w.north - bounds.south) / (bounds.north - bounds.south), -}); const query = (url) => new URL(url, 'https://example.test').searchParams; +const WINDOW = box(-102, 34, -96, 37); test('a detail window follows the view after the full-extent frame, on the same surface', async () => { - const view = camera(-100, 35, -98, 36); - const h = harness({ camera: view }); + const h = harness({ view: [-100, 35, -98, 36] }); const pending = h.shell.setFrame(snapshot(), times[0]); await flush(); assert.equal(h.fetches.length, 1, 'the full-extent image comes first'); - h.render(); + assert.equal(h.detail(), null); + await h.render(); assert.equal(await pending, true); - assert.equal(view.moveEnd.size, 1); - await flush(); + assert.equal(h.listeners(), 1); assert.equal(h.fetches.length, 2); assert.equal(query(h.fetches[1].url).get('bbox'), '-102,34,-96,37'); assert.equal(query(h.fetches[1].url).get('time'), times[0]); assert.equal(query(h.fetches[1].url).get('size'), null); - const [shell] = h.primitives(); - assert.equal(h.primitives().length, 1, 'no second surface'); - const { uniforms } = shell.appearance.material; - assert.equal(uniforms.detail.source, h.decoded[1]); - assert.deepEqual(windowOf(shell), NONE, 'the full-extent image covers'); + const [shell] = h.ids(); + assert.equal(h.ids().length, 1, 'no second surface'); + assert.equal(h.detail().image.source, h.decoded[1]); + assertBox(h.detail().rectangle, WINDOW); assert.deepEqual(h.shell.getDiagnostics().shell.detail, { bbox: [-102, 34, -96, 37], size: { width: 4096, height: 2048 }, - ready: false, + ready: true, enabled: true, }); - h.render(); - assert.deepEqual(windowOf(shell), windowFor(box(-102, 34, -96, 37))); - assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); assert.equal(h.shell.getDiagnostics().imageryCount, 1); - assert.equal(h.scene.postRender.size, 0); - view.look(-99.5, 35.2, -97.5, 36.2); + h.look(-99.5, 35.2, -97.5, 36.2); await flush(); assert.equal( h.fetches.length, @@ -1091,13 +728,8 @@ test('a detail window follows the view after the full-extent frame, on the same 'panning inside the window fetches nothing', ); - view.look(-90, 40, -88, 41); - assert.equal( - uniforms.detail, - Cesium.Material.DefaultImageId, - 'a moved window clears at once', - ); - assert.deepEqual(windowOf(shell), NONE); + h.look(-90, 40, -88, 41); + assert.equal(h.detail(), null, 'a moved window clears at once'); assert.deepEqual(h.shell.getDiagnostics().shell.detail, { bbox: [-92, 39, -86, 42], size: { width: 4096, height: 2048 }, @@ -1106,20 +738,12 @@ test('a detail window follows the view after the full-extent frame, on the same }); await flush(); assert.equal(query(h.fetches[2].url).get('bbox'), '-92,39,-86,42'); - assert.equal(uniforms.detail.source, h.decoded[2]); - assert.deepEqual(windowOf(shell), NONE, 'until the new image has drawn'); - assert.equal(h.shell.getDiagnostics().shell.detail.ready, false); - h.render(); - assert.deepEqual(windowOf(shell), windowFor(box(-92, 39, -86, 42))); + assert.equal(h.detail().image.source, h.decoded[2]); + assertBox(h.detail().rectangle, box(-92, 39, -86, 42)); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); - view.look(-120, 25, -100, 50); - assert.equal( - uniforms.detail, - Cesium.Material.DefaultImageId, - 'a wide view needs no window', - ); - assert.deepEqual(windowOf(shell), NONE); + h.look(-120, 25, -100, 50); + assert.equal(h.detail(), null, 'a wide view needs no window'); assert.deepEqual(h.shell.getDiagnostics().shell.detail, { bbox: null, size: { width: 4096, height: 2048 }, @@ -1128,56 +752,44 @@ test('a detail window follows the view after the full-extent frame, on the same }); await flush(); assert.equal(h.fetches.length, 3); - assert.deepEqual(h.primitives(), [shell]); + assert.deepEqual(h.ids(), [shell]); assert.equal(h.shell.getDiagnostics().imageryCount, 1); h.shell.clear(); - assert.equal(view.moveEnd.size, 0); + assert.equal(h.listeners(), 0); }); test('a new extent restages the surface and the detail follows onto it', async () => { - const h = harness({ camera: camera(-100, 35, -98, 36) }); + const h = harness({ view: [-100, 35, -98, 36] }); assert.equal(await show(h, times[0]), true); - await flush(); - h.render(); - const [first] = h.primitives(); - const detail = first.appearance.material.uniforms.detail; + await h.render(); + const [first] = h.ids(); + const detail = h.detail().image; const wider = { ...bounds, west: -140 }; const pending = h.shell.setFrame( snapshot('radar', { bounds: wider }), times[0], ); await flush(); - const [, second] = h.primitives(); - assert.equal( - second.appearance.material.uniforms.detail, - Cesium.Material.DefaultImageId, - ); + const [, second] = h.ids(); + assert.equal(h.detail(second), null); assert.equal(h.shell.getDiagnostics().imageryCount, 2, 'staged, no detail'); - h.render(); + await h.render(); assert.equal(await pending, true); - assert.deepEqual(h.primitives(), [second]); - assert.equal(first.destroyed, true); - assert.equal(second.appearance.material.uniforms.detail, detail); - assert.deepEqual(windowOf(second), NONE, 'a new mesh uploads it first'); - h.render(); - const x = (edge) => (edge - wider.west) / (wider.east - wider.west); - assert.deepEqual(windowOf(second), { - x: x(-102), - y: windowFor(box(-102, 34, -96, 37)).y, - z: x(-96), - w: windowFor(box(-102, 34, -96, 37)).w, - }); + assert.deepEqual(h.ids(), [second]); + assert.equal(h.layer(first), undefined); + assert.equal(h.detail(second).image, detail, 'the same detail image'); + assertBox(h.detail(second).rectangle, WINDOW); + assert.equal(h.fetches.length, 2, 'nothing fetched again'); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); h.shell.clear(); }); test('a new frame swaps the full-extent image first and keeps the detail until its own image decodes', async () => { - const view = camera(-100, 35, -98, 36); const urls = []; let gate = null; let offline = false; const h = harness({ - camera: view, + view: [-100, 35, -98, 36], fetchImpl: async (url) => { urls.push(url); if (offline && url.includes('bbox=')) throw new Error('offline'); @@ -1189,31 +801,26 @@ test('a new frame swaps the full-extent image first and keeps the detail until i }, }); assert.equal(await show(h, times[0]), true); - await flush(); - h.render(); - const [shell] = h.primitives(); - const { uniforms } = shell.appearance.material; - const previous = { image: uniforms.image, detail: uniforms.detail }; - const applied = uniforms.window; + await h.render(); + const previous = { image: h.image(), detail: h.detail().image }; gate = deferred(); assert.equal(await h.shell.setFrame(snapshot(), times[1]), true); await flush(); - assert.notEqual(uniforms.image, previous.image); + assert.notEqual(h.image(), previous.image); assert.equal(h.shell.getDiagnostics().time, times[1]); assert.equal(query(urls.at(-1)).get('time'), times[1]); assert.equal(query(urls.at(-1)).get('bbox'), '-102,34,-96,37'); - assert.equal(uniforms.detail, previous.detail, 'the previous detail stays'); - assert.equal(uniforms.window, applied); + assert.equal(h.detail().image, previous.detail, 'the previous detail stays'); + assertBox(h.detail().rectangle, WINDOW); assert.equal(h.shell.getDiagnostics().shell.detail.ready, false); gate.resolve(); await flush(); - assert.notEqual(uniforms.detail, previous.detail); - assert.equal( - uniforms.window, - applied, + assert.notEqual(h.detail().image, previous.detail); + assertBox( + h.detail().rectangle, + WINDOW, 'the same window swaps only the image', ); - h.render(); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); gate = null; @@ -1221,11 +828,10 @@ test('a new frame swaps the full-extent image first and keeps the detail until i assert.equal(await h.shell.setFrame(snapshot(), times[2]), true); await flush(); assert.equal( - uniforms.detail, - Cesium.Material.DefaultImageId, + h.detail(), + null, 'a failed detail leaves no older one over the frame', ); - assert.deepEqual(windowOf(shell), NONE); assert.equal(h.shell.getDiagnostics().time, times[2]); assert.equal(h.shell.getDiagnostics().error, null); h.shell.clear(); @@ -1237,11 +843,10 @@ test('an older detail stays over at most one newer frame; a warmed detail follow new Date(Date.parse(times[0]) + i * 300_000).toISOString(), ); const playback = snapshot('radar', { times: all, latest: all.at(-1) }); - const rect = windowFor(box(-102, 34, -96, 37)); const advance = async (h, time) => { const pending = h.shell.setFrame(playback, time); await flush(); - h.render(); + await h.render(); assert.equal(await pending, true); await flush(); }; @@ -1249,7 +854,7 @@ test('an older detail stays over at most one newer frame; a warmed detail follow const requests = []; let slow = false; const h = harness({ - camera: camera(-100, 35, -98, 36), + view: [-100, 35, -98, 36], fetchImpl: async (url, init) => { requests.push({ url, signal: init.signal }); return response(); @@ -1260,27 +865,18 @@ test('an older detail stays over at most one newer frame; a warmed detail follow : { width: 4096, height: 2048, close() {} }, }); await advance(h, all[0]); - h.render(); - const [shell] = h.primitives(); - const { uniforms } = shell.appearance.material; - const first = uniforms.detail; - assert.deepEqual(windowOf(shell), rect); + const first = h.detail().image; + assertBox(h.detail().rectangle, WINDOW); slow = true; await advance(h, all[1]); - assert.equal(uniforms.detail, first, 'over one newer frame'); - assert.deepEqual(windowOf(shell), rect); + assert.equal(h.detail().image, first, 'over one newer frame'); + assertBox(h.detail().rectangle, WINDOW); assert.equal(h.shell.getDiagnostics().shell.detail.ready, false); await advance(h, all[2]); - assert.equal( - uniforms.detail, - Cesium.Material.DefaultImageId, - 'cleared at the second', - ); - assert.deepEqual(windowOf(shell), NONE); + assert.equal(h.detail(), null, 'cleared at the second'); for (const time of all.slice(3)) { await advance(h, time); - assert.equal(uniforms.detail, Cesium.Material.DefaultImageId); - assert.deepEqual(windowOf(shell), NONE); + assert.equal(h.detail(), null); } const details = requests.filter(({ url }) => url.includes('bbox=')); assert.deepEqual( @@ -1294,79 +890,76 @@ test('an older detail stays over at most one newer frame; a warmed detail follow assert.equal(details.at(-1).signal.aborted, false); h.shell.clear(); - const warm = harness({ camera: camera(-100, 35, -98, 36) }); + const warm = harness({ view: [-100, 35, -98, 36] }); await advance(warm, all[0]); - warm.render(); - const [surface] = warm.primitives(); - const material = surface.appearance.material; - const applied = material.uniforms.window; - assert.deepEqual(windowOf(surface), rect); + assertBox(warm.detail().rectangle, WINDOW); + // The window never empties: every detail the surface is handed is an image. + let emptied = 0; + const id = warm.ids()[0]; for (const time of all.slice(1)) { assert.equal(await warm.shell.prefetch(playback, time), true); const fetches = warm.fetches.length; - const previous = material.uniforms.detail; - await advance(warm, time); + const previous = warm.detail().image; + const pending = warm.shell.setFrame(playback, time); + for (let i = 0; i < 30; i++) { + if (warm.detail(id) === null) emptied++; + await Promise.resolve(); + } + await warm.render(); + assert.equal(await pending, true); assert.equal(warm.fetches.length, fetches, 'both images from the cache'); - assert.notEqual(material.uniforms.detail, previous, time); - assert.equal(material.uniforms.window, applied, 'the window never empties'); + assert.notEqual(warm.detail().image, previous, time); + assertBox(warm.detail().rectangle, WINDOW); assert.equal(warm.shell.getDiagnostics().shell.detail.ready, true); } + assert.equal(emptied, 0, 'the window never empties'); warm.shell.clear(); }); test('hiding, a host without imagery and clear hide or release the detail with its surface', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - const view = camera(-100, 35, -98, 36); - const h = harness({ camera: view }); + const h = harness({ view: [-100, 35, -98, 36] }); assert.equal(await show(h, times[0]), true); - await flush(); - h.render(); - const [shell] = h.primitives(); - const rect = windowFor(box(-102, 34, -96, 37)); + await h.render(); + const [shell] = h.ids(); h.shell.setHidden(true); - assert.equal(shell.show, false); - assert.deepEqual(windowOf(shell), rect, 'the detail hides with its surface'); + assert.equal(h.layer().shown, false); + assertBox(h.detail().rectangle, WINDOW, 'the detail hides with its surface'); assert.equal(h.shell.getDiagnostics().shell.detail.ready, false); h.shell.setHidden(false); - assert.equal(shell.show, true); - assert.deepEqual(windowOf(shell), rect); + assert.equal(h.layer().shown, true); + assertBox(h.detail().rectangle, WINDOW); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); h.shell.setAlpha(0.4); - assert.equal(shell.appearance.material.uniforms.alpha, 0.4); + assert.equal(h.layer().alpha, 0.4); h.setHost({ collection: null, kind: 'none' }); h.shell.rehome(); - assert.equal(shell.show, false); - view.look(-90, 40, -88, 41); + assert.equal(h.layer().shown, false); + h.look(-90, 40, -88, 41); await flush(); assert.equal(h.fetches.length, 2, 'no window work without a host'); - assert.deepEqual(windowOf(shell), NONE, 'the moved window is dropped'); + assert.equal(h.detail(), null, 'the moved window is dropped'); h.setHost({ collection: null, kind: 'tileset' }); h.shell.rehome(); await flush(); assert.equal(query(h.fetches[2].url).get('bbox'), '-92,39,-86,42'); - h.render(); - assert.deepEqual(windowOf(shell), windowFor(box(-92, 39, -86, 42))); - assert.deepEqual(h.primitives(), [shell]); + assertBox(h.detail().rectangle, box(-92, 39, -86, 42)); + assert.deepEqual(h.ids(), [shell]); // Frames shown while hidden leave no older detail to reappear. h.shell.setHidden(true); for (const time of [times[1], times[2]]) assert.equal(await h.shell.setFrame(snapshot(), time), true); h.shell.setHidden(false); - assert.equal( - shell.appearance.material.uniforms.detail, - Cesium.Material.DefaultImageId, - ); + assert.equal(h.detail(), null); await flush(); assert.equal(query(h.fetches.at(-1).url).get('time'), times[2]); - h.render(); + await h.render(); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); h.shell.clear(); - assert.equal(shell.destroyed, true); - assert.equal(shell.appearance.material.destroyed, true); - assert.equal(view.moveEnd.size, 0); - assert.equal(h.scene.postRender.size, 0); + assert.deepEqual(h.ids(), []); + assert.equal(h.listeners(), 0); assert.deepEqual(h.shell.getDiagnostics().cache, { mosaics: 0, bytes: 0, @@ -1377,7 +970,7 @@ test('hiding, a host without imagery and clear hide or release the detail with i const gate = deferred(); const requests = []; const late = harness({ - camera: camera(-100, 35, -98, 36), + view: [-100, 35, -98, 36], fetchImpl: async (url, init) => { requests.push({ url, signal: init.signal }); return response(); @@ -1394,19 +987,17 @@ test('hiding, a host without imagery and clear hide or release the detail with i assert.equal(requests[1].signal.aborted, true); gate.resolve(); await flush(); - assert.equal(late.primitives().length, 0); + assert.deepEqual(late.ids(), []); assert.equal(late.shell.getDiagnostics().cache.mosaics, 0); t.mock.timers.tick(30_000); - assert.equal(h.primitives().length, 0); + assert.deepEqual(h.ids(), []); }); test('full-extent and detail images share one byte budget; prefetch warms both', async () => { - const view = camera(-100, 35, -98, 36); - const h = harness({ camera: view }); + const h = harness({ view: [-100, 35, -98, 36] }); const MiB = 1024 * 1024; assert.equal(await show(h, times[0]), true); - await flush(); - h.render(); + await h.render(); assert.deepEqual(h.shell.getDiagnostics().cache, { mosaics: 2, bytes: 64 * MiB, @@ -1433,8 +1024,7 @@ test('full-extent and detail images share one byte budget; prefetch warms both', 'completed work is not repeated', ); assert.equal(await show(h, times[1]), true); - await flush(); - h.render(); + await h.render(); assert.equal(h.fetches.length, 4, 'both images came from the cache'); assert.equal(h.shell.getDiagnostics().mosaic.cached, true); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); @@ -1453,8 +1043,7 @@ test('full-extent and detail images share one byte budget; prefetch warms both', }); for (const time of [times[2], times[1]]) { assert.equal(await show(h, time), true); - await flush(); - h.render(); + await h.render(); assert.equal(h.fetches.length, 6, `${time} full-extent and detail cached`); assert.equal(h.shell.getDiagnostics().shell.detail.ready, true); } @@ -1464,7 +1053,7 @@ test('full-extent and detail images share one byte budget; prefetch warms both', assert.equal(h.shell.getDiagnostics().cache.bytes, 128 * MiB); h.shell.clear(); - const wide = harness({ camera: camera(-120, 25, -100, 50) }); + const wide = harness({ view: [-120, 25, -100, 50] }); assert.equal(await show(wide, times[0]), true); assert.equal(await wide.shell.prefetch(snapshot(), times[1]), true); assert.equal(wide.fetches.length, 2, 'no window, no detail prefetch'); @@ -1472,38 +1061,19 @@ test('full-extent and detail images share one byte budget; prefetch warms both', wide.shell.clear(); }); -test('global infrared keeps one full-extent image; detail images halve for small texture limits', async () => { - const view = camera(-100, 35, -98, 36); - const clouds = harness({ product: 'clouds', camera: view }); +test('global infrared keeps one full-extent image and no detail window', async () => { + const clouds = harness({ product: 'clouds', view: [-100, 35, -98, 36] }); assert.equal(await show(clouds, times[0]), true); await flush(); - assert.equal(view.moveEnd.size, 0); + assert.equal(clouds.listeners(), 0); assert.equal(clouds.fetches.length, 1); assert.equal(clouds.shell.getDiagnostics().shell.detail.enabled, false); assert.equal(await clouds.shell.prefetch(snapshot('clouds'), times[1]), true); assert.ok(clouds.fetches.every(({ url }) => !url.includes('bbox='))); clouds.shell.clear(); - - const small = harness({ - camera: camera(-100, 35, -98, 36), - cesium: createShellCesium({ maximumTextureSize: 2048 }), - decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), - }); - assert.equal(await show(small, times[0]), true); - await flush(); - small.render(); - assert.equal(query(small.fetches[1].url).get('size'), '2048x1024'); - assert.equal(query(small.fetches[1].url).get('bbox'), '-102,34,-96,37'); - assert.deepEqual(small.shell.getDiagnostics().shell.detail, { - bbox: [-102, 34, -96, 37], - size: { width: 2048, height: 1024 }, - ready: true, - enabled: true, - }); - small.shell.clear(); }); -test('a Navara shell runs without the Cesium SDK and hands its surface the extent in radians', async () => { +test('the shell hands its surface the height and the extent in radians', async () => { const surfaces = []; const shell = createWeatherShell({ product: 'radar', @@ -1522,22 +1092,23 @@ test('a Navara shell runs without the Cesium SDK and hands its surface the exten return { setAlpha() {}, setImage() {}, - setWindow() {}, setDetail() {}, - setHidden() {}, + setShow: () => false, destroy() {}, + getDiagnostics: () => null, }; }, }); - shell.setFrame(snapshot('radar'), '2026-11-04T12:00:00Z'); + shell.setFrame(snapshot('radar'), times[0]); await flush(); assert.equal(surfaces.length, 1); - assert.equal(surfaces[0].cesium, undefined); - const { bounds } = snapshot('radar'); + assert.deepEqual(Object.keys(surfaces[0]).sort(), [ + 'height', + 'onSettled', + 'rectangle', + ]); + assert.equal(surfaces[0].height, WEATHER_SHELL_HEIGHTS.radar); for (const side of ['west', 'south', 'east', 'north']) - assert.equal( - surfaces[0].rectangle[side], - Cesium.Math.toRadians(bounds[side]), - ); + assert.equal(surfaces[0].rectangle[side], geo.math.toRadians(bounds[side])); shell.clear(); }); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index a42485ed0..ed5ef91b9 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -1,17 +1,12 @@ -import test from 'node:test'; +// The weather layer on the fake and headless Cesium engines, and its display's frames there: +// staging, retained frames, the mosaic cache, prefetch and host switches. import assert from 'node:assert/strict'; -import { createWeatherRendering, READY_WAKE_MS } from './rendering.js'; import { createWeatherLayer } from './index.js'; import { createWeatherClock } from './clock.js'; -import * as Cesium from 'cesium'; -import { Color, ImageryLayerCollection, GeographicTilingScheme } from 'cesium'; +import { createWeatherDisplay } from './render/display.js'; import { NO_IMAGERY_HOST } from './imageryHost.js'; -import { orderWeatherImagery } from './imageryOrder.js'; -import { - createShellCesium, - createShellScene, - renderShells, -} from './shellFixture.mjs'; +import { imageryDisplayTests } from '../../testSupport/imageryDisplayEngines.mjs'; +const { test, createEngine } = imageryDisplayTests(); const times = [ '2026-09-15T20:00:00.000Z', @@ -35,21 +30,6 @@ const deferred = () => { const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; -function event() { - const listeners = new Set(); - return { - addEventListener(fn) { - listeners.add(fn); - return () => listeners.delete(fn); - }, - emit(...args) { - for (const fn of [...listeners]) fn(...args); - }, - get size() { - return listeners.size; - }, - }; -} function target(extra = {}) { const listeners = new Map(); return { @@ -81,7 +61,6 @@ function mockCanvas() { drawImage(source, ...crop) { canvas.source = source; canvas.crop = crop; - canvas.drawnWith = canvas.transforms?.at(-1) ?? null; }, getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }), putImageData(pixels) { @@ -95,383 +74,823 @@ const mockResponse = () => ({ headers: new Headers(), arrayBuffer: async () => new ArrayBuffer(1), }); -function renderingHarness(options = {}) { - const layers = []; - const providers = []; - const postRender = event(); - let renderRequests = 0; - class Provider { - constructor(options) { - this.options = options; - this.tilingScheme = new GeographicTilingScheme(); - this.errorEvent = event(); - this.response = null; - providers.push(this); - } - requestImage(...args) { - this.requestArgs = args; - return this.response?.promise; - } - } - const collection = { - add(layer) { - layers.push(layer); - return layer; - }, - addImageryProvider(provider) { - if (!providers.includes(provider)) providers.push(provider); - const layer = { imageryProvider: provider, alpha: 1, show: true }; - layers.push(layer); - return layer; - }, - contains(layer) { - return layers.includes(layer); - }, - remove(layer, destroy) { - const index = layers.indexOf(layer); - if (index < 0) return false; - layers.splice(index, 1); - layer.destroyed = destroy; - return true; - }, - get length() { - return layers.length; - }, - get(index) { - return layers[index]; - }, - lower(layer) { - const i = layers.indexOf(layer); - if (i > 0) [layers[i - 1], layers[i]] = [layers[i], layers[i - 1]]; - }, - raiseToTop(layer) { - const i = layers.indexOf(layer); - if (i >= 0) layers.push(...layers.splice(i, 1)); - }, - isDestroyed: () => false, - }; - const viewer = { - clock: { untouched: true }, - imageryLayers: collection, - scene: { - postRender, - primitives: createShellScene().primitives, - globe: { tilesLoaded: true }, - requestRender() { - renderRequests++; - }, - }, - }; - const cesium = { - ...createShellCesium(), - Color, - UrlTemplateImageryProvider: Provider, - GeographicTilingScheme, - Event: Cesium.Event, - Credit: Cesium.Credit, - Rectangle: Cesium.Rectangle, - }; - const rendering = createWeatherRendering({ - viewer, - cesium, - fetchImpl: async () => mockResponse(), - decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), - createCanvas: mockCanvas, - ...options, - }); - const settle = () => { - postRender.emit(); - postRender.emit(); +// A rectangle in degrees as the engine camera's view rectangle (radians). +const view = (west, south, east, north) => ({ + west: (west * Math.PI) / 180, + south: (south * Math.PI) / 180, + east: (east * Math.PI) / 180, + north: (north * Math.PI) / 180, +}); + +/** + * The fake engine with its imagery surface set by the test and, while + * `hold()` is on, staged sources held until the test finishes them: + * `stages` lists them ({ source, signal, finish(ok = true) }). + */ +function fakeImagery() { + const engine = createEngine(); + let surface = 'globe'; + engine.imagery.surface = () => surface; + // A view over the radar product, too wide for a 3D Tiles detail window. + engine.fake.script('viewRectangle', view(-120, 25, -100, 50)); + const stages = []; + let holding = false; + const createLayer = engine.imagery.createLayer; + engine.imagery.createLayer = (input) => { + const layer = createLayer(input); + const setSource = layer.setSource; + layer.setSource = (source, options = {}) => { + if (!options.staged || !holding) return setSource(source, options); + const task = deferred(); + stages.push({ + source, + signal: options.signal, + finish: (ok = true) => + task.resolve(ok ? setSource(source, options) : false), + }); + return task.promise; + }; + return layer; }; + const layers = () => + engine.fake.imageryLayers().map((id) => ({ + id, + ...engine.fake.imageryLayer(id), + })); return { - rendering, - viewer, - cesium, - providers, + engine, + stages, layers, - postRender, - settle, - shells: () => viewer.scene.primitives.items, - renderShells: () => renderShells(cesium, viewer.scene), - renders: () => renderRequests, + setSurface(next) { + surface = next; + }, + hold(value = true) { + holding = value; + }, + moveEnd: engine.camera.moveEnd, }; } -test('a ready weather frame does not wait for unrelated terrain, but requires successful quiet tiles', async () => { - let now = 0; - const h = renderingHarness({ now: () => now }); - h.viewer.scene.globe.tilesLoaded = false; - const loaded = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - assert.equal( - h.rendering.getDiagnostics().loading, - true, - 'no successful tiles is not ready', - ); - const tile = deferred(); - h.providers[0].response = tile; - const request = h.providers[0].requestImage(0, 0, 0, {}); - tile.resolve({}); - await request; - now = 199; - h.settle(); - assert.equal( - h.rendering.getDiagnostics().loading, - true, - 'scheduler quiet interval is required', - ); - h.providers[0].response = null; - assert.equal(h.providers[0].requestImage(1, 0, 0, {}), undefined); - now = 500; - h.settle(); - assert.equal( - h.rendering.getDiagnostics().loading, - true, - 'a scheduler-deferred own tile prevents premature commit', - ); - const delayed = deferred(); - h.providers[0].response = delayed; - const admitted = h.providers[0].requestImage(1, 0, 0, {}); - delayed.resolve({}); - await admitted; - now = 701; - h.settle(); - assert.equal(await loaded, true); - assert.equal(h.rendering.getDiagnostics().loadedTiles, 2); - assert.equal(h.rendering.getDiagnostics().frameLoadMs, 701); - h.rendering.clear(); -}); +const displayOptions = { + fetchImpl: async () => mockResponse(), + decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), + createCanvas: mockCanvas, +}; -test('a staged frame waiting out its quiet interval wakes on a timer, not every render', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - let now = 0; - const h = renderingHarness({ now: () => now }); - h.viewer.scene.globe.tilesLoaded = false; - const loaded = h.rendering.setFrame(snapshot, times[0]); - const tile = deferred(); - h.providers[0].response = tile; - const request = h.providers[0].requestImage(0, 0, 0, {}); - tile.resolve({}); - await request; - const before = h.renders(); - for (let i = 0; i < 10; i++) h.postRender.emit(); - assert.equal( - h.renders(), - before, - 'renders while waiting do not request further renders', - ); - t.mock.timers.tick(READY_WAKE_MS - 1); - assert.equal(h.renders(), before); - t.mock.timers.tick(1); - assert.equal(h.renders(), before + 1, 'one wake per interval'); - now = 200; - h.postRender.emit(); - h.postRender.emit(); - assert.equal(await loaded, true); - const settledRenders = h.renders(); - t.mock.timers.tick(READY_WAKE_MS * 4); - assert.equal(h.renders(), settledRenders, 'commit clears the wake timer'); - - const abandoned = h.rendering.setFrame(snapshot, times[1]); - const second = deferred(); - h.providers.at(-1).response = second; - const secondRequest = h.providers.at(-1).requestImage(0, 0, 0, {}); - second.resolve({}); - await secondRequest; - h.postRender.emit(); - h.rendering.clear(); - assert.equal(await abandoned, false); - const clearedRenders = h.renders(); - t.mock.timers.tick(READY_WAKE_MS * 4); - assert.equal(h.renders(), clearedRenders, 'clear cancels the wake timer'); -}); - -test('camera movement drops abandoned deferred tiles but still waits for admitted requests', async () => { - let now = 0; - const h = renderingHarness({ now: () => now }); - const moveEnd = event(); - h.viewer.camera = { moveEnd }; - h.viewer.scene.globe.tilesLoaded = false; - const stage = h.rendering.setFrame(snapshot, times[0]); - assert.equal(moveEnd.size, 1); - const provider = h.providers[0]; - const first = deferred(); - provider.response = first; - const firstRequest = provider.requestImage(0, 0, 0, {}); - first.resolve({}); - await firstRequest; - provider.response = null; - assert.equal(provider.requestImage(1, 0, 0, {}), undefined); - const pending = deferred(); - provider.response = pending; - const admitted = provider.requestImage(2, 0, 0, {}); - assert.equal(h.rendering.getDiagnostics().deferredTiles, 1); - assert.equal(h.rendering.getDiagnostics().pendingTiles, 1); - now = 500; - moveEnd.emit(); - assert.equal( - h.rendering.getDiagnostics().deferredTiles, - 0, - 'an unadmitted tile abandoned by the old viewport no longer blocks the stage', - ); - assert.equal( - h.rendering.getDiagnostics().pendingTiles, - 1, - 'camera movement does not erase admitted request ownership', - ); - now = 800; - h.settle(); - assert.equal(h.rendering.getDiagnostics().loading, true); - assert.equal( - h.layers[0].alpha, - 0, - 'the new observation stays invisible while a request is pending', - ); - pending.resolve({}); - await admitted; - now = 999; - h.settle(); - assert.equal( - h.rendering.getDiagnostics().loading, - true, - 'completion starts a new quiet interval', - ); - now = 1000; - h.postRender.emit(); - assert.equal( - h.rendering.getDiagnostics().loading, - true, - 'commit still requires two settled renders', - ); - h.postRender.emit(); - assert.equal(await stage, true); - assert.equal(h.rendering.getDiagnostics().loadedTiles, 2); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - assert.equal(moveEnd.size, 0, 'committing releases the stage listener'); - const abandoned = h.rendering.setFrame(snapshot, times[1]); - assert.equal(moveEnd.size, 1); - h.rendering.clear(); - assert.equal(await abandoned, false); - assert.equal( - moveEnd.size, - 0, - 'teardown also releases the stage camera listener', - ); -}); - -test('weather stages invisibly, waits for pending tiles and render readiness, and replaces atomically', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = renderingHarness(); - const first = h.rendering.setFrame(snapshot, times[0]); - const layer = h.layers[0]; - assert.equal(layer.alpha, 0); - assert.equal(layer.show, true); - assert.equal(h.rendering.getDiagnostics().time, null); - const tile = deferred(); - h.providers[0].response = tile; - const requested = h.providers[0].requestImage(0, 0, 0, { cancel() {} }); - h.settle(); - assert.equal(h.rendering.getDiagnostics().loading, true); - tile.resolve({}); - await requested; - h.viewer.scene.globe.tilesLoaded = false; - h.settle(); - assert.equal(h.rendering.getDiagnostics().loading, true); - h.viewer.scene.globe.tilesLoaded = true; - h.postRender.emit(); - assert.equal( - h.rendering.getDiagnostics().time, - null, - 'one settled frame is insufficient', - ); - h.postRender.emit(); - assert.equal(await first, true); - assert.equal(layer.alpha, 0.7); - const second = h.rendering.setFrame(snapshot, times[1]); - assert.equal(h.layers.length, 2); - assert.equal( - h.rendering.getDiagnostics().time, - times[0], - 'displayed time remains old until commit', - ); - h.settle(); - assert.equal(await second, true); - assert.equal(h.layers.length, 2, 'old layer survives the admission render'); - assert.equal( - h.layers[1].alpha, - 0.7, - 'new layer is visible before retirement', - ); - assert.equal(layer.destroyed, undefined); - h.postRender.emit(); - assert.equal(h.layers.length, 1); - assert.equal(layer.destroyed, true); - assert.equal(h.providers[0].errorEvent.size, 0); - assert.equal(h.rendering.getDiagnostics().time, times[1]); - assert.deepEqual(h.viewer.clock, { untouched: true }); - h.rendering.clear(); -}); - -test('superseded stages cancel requests and cannot resurrect after a late tile response', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = renderingHarness(); - const first = h.rendering.setFrame(snapshot, times[0]); - const tile = deferred(); - const oldProvider = h.providers[0]; - oldProvider.response = tile; - let cancellations = 0; - const requested = oldProvider.requestImage(0, 0, 0, { - cancel() { - cancellations++; - }, +/** The weather display on the fake and headless Cesium engines. */ +function displayHarness(options = {}) { + const imagery = fakeImagery(); + const display = createWeatherDisplay(imagery.engine, { + ...displayOptions, + ...options, }); - const second = h.rendering.setFrame(snapshot, times[1]); - assert.equal(await first, false); - assert.equal(cancellations, 1); - assert.equal(h.layers.length, 1); - assert.equal(oldProvider.errorEvent.size, 0); - tile.resolve({}); - await requested; - h.settle(); + return { ...imagery, display }; +} + +test('a staged frame keeps the shown one until it draws, then replaces it in place', async () => { + const h = displayHarness(); + h.hold(); + const first = h.display.setFrame(snapshot, times[0]); + const [layer] = h.layers(); + assert.equal( + layer.source.kind, + 'image', + 'nothing draws before the first frame', + ); + assert.equal(h.display.getDiagnostics().time, null); + assert.equal(h.display.getDiagnostics().loading, true); + h.stages[0].finish(); + assert.equal(await first, true); + assert.equal(h.layers()[0].alpha, 0.7); + const second = h.display.setFrame(snapshot, times[1]); + assert.equal( + h.display.getDiagnostics().time, + times[0], + 'displayed time remains old until the new frame draws', + ); + assert.match(h.layers()[0].source.url, /20%3A00/); + h.stages[1].finish(); assert.equal(await second, true); - assert.equal(h.rendering.getDiagnostics().time, times[1]); - assert.equal(oldProvider.requestImage(0, 0, 0, {}), undefined); - h.rendering.clear(); - assert.equal(h.layers.length, 0); - assert.equal(h.postRender.size, 0); - assert.ok(h.providers.every((provider) => provider.errorEvent.size === 0)); + assert.deepEqual( + h.layers().map(({ id }) => id), + [layer.id], + ); + assert.match(h.layers()[0].source.url, /20%3A05/); + assert.equal(h.display.getDiagnostics().time, times[1]); + h.display.clear(); +}); + +test('superseded stages are cancelled and cannot resurrect after a late draw', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = displayHarness(); + h.hold(); + const first = h.display.setFrame(snapshot, times[0]); + const second = h.display.setFrame(snapshot, times[1]); + assert.equal(await first, false); + assert.equal(h.stages[0].signal.aborted, true); + h.stages[0].finish(); + await flush(); + assert.equal(h.display.getDiagnostics().time, null); + h.stages[1].finish(); + assert.equal(await second, true); + assert.equal(h.display.getDiagnostics().time, times[1]); + assert.match(h.layers()[0].source.url, /20%3A05/); + h.display.clear(); + assert.deepEqual(h.layers(), []); t.mock.timers.tick(30_000); - assert.equal(h.layers.length, 0); + assert.deepEqual(h.layers(), []); }); test('failed and timed-out stages retain the previous observation; a healthy replacement clears old errors', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = renderingHarness(); - const first = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - await first; - const failed = h.rendering.setFrame(snapshot, times[1]); - h.providers[1].errorEvent.emit(new Error('tile missing')); - h.postRender.emit(); + const h = displayHarness(); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + h.hold(); + const failed = h.display.setFrame(snapshot, times[1]); + h.stages.at(-1).finish(false); assert.equal(await failed, false); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - assert.equal(h.layers.length, 1); - const timeout = h.rendering.setFrame(snapshot, times[1]); - h.viewer.scene.globe.tilesLoaded = false; + assert.equal(h.display.getDiagnostics().time, times[0]); + assert.equal( + h.display.getDiagnostics().error, + 'Weather tiles unavailable · previous frame retained', + ); + assert.equal(h.layers().length, 1); + const timeout = h.display.setFrame(snapshot, times[1]); t.mock.timers.tick(25_000); assert.equal(await timeout, false); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - const healthy = h.rendering.setFrame(snapshot, times[2]); - h.providers[0].errorEvent.emit( - new Error('old visible tile failed while replacement loaded'), - ); - h.viewer.scene.globe.tilesLoaded = true; - h.settle(); - assert.equal(await healthy, true); - assert.equal(h.rendering.getDiagnostics().error, null); - h.rendering.clear(); + assert.equal(h.display.getDiagnostics().time, times[0]); + assert.equal(h.stages.at(-1).signal.aborted, true); + h.hold(false); + assert.equal(await h.display.setFrame(snapshot, times[2]), true); + assert.equal(h.display.getDiagnostics().error, null); + assert.equal(h.display.getDiagnostics().time, times[2]); + h.display.clear(); }); +test('external abort cancels a staged request and releases its deadline', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = displayHarness(); + h.hold(); + const controller = new AbortController(); + const stage = h.display.setFrame(snapshot, times[0], { + signal: controller.signal, + }); + const before = h.engine.fake.renderRequests; + controller.abort(); + assert.equal(await stage, false); + assert.equal(h.stages[0].signal.aborted, true); + assert.ok( + h.engine.fake.renderRequests > before, + 'cancellation requests a render', + ); + h.stages[0].finish(); + t.mock.timers.tick(30_000); + await flush(); + assert.equal(h.display.getDiagnostics().time, null); + assert.equal(h.display.getDiagnostics().loading, false); + await assert.rejects( + h.display.setFrame(snapshot, times[1], { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(h.stages.length, 1, 'already-aborted work stages nothing'); + h.display.clear(); +}); + +test('global infrared is acquired before staging and drawn as one processed mosaic', async () => { + let fetches = 0; + const decode = deferred(); + const h = displayHarness({ + fetchImpl: async () => { + fetches++; + return mockResponse(); + }, + decodeImage: () => decode.promise, + }); + const task = h.display.setFrame({ ...snapshot, product: 'clouds' }, times[0]); + await flush(); + assert.deepEqual(h.layers(), []); + assert.equal(h.display.getDiagnostics().loading, true); + assert.deepEqual(h.display.getDiagnostics().mosaic, { + fetched: true, + decodeMs: null, + cached: false, + }); + decode.resolve({ width: 2048, height: 1024 }); + assert.equal(await task, true); + const [layer] = h.layers(); + assert.equal(layer.source.kind, 'image'); + assert.deepEqual( + layer.source.image.pixels, + new Uint8ClampedArray([194, 100, 0, 98]), + ); + assert.equal(fetches, 1); + assert.equal(h.display.getDiagnostics().mosaic.fetched, true); + assert.equal(typeof h.display.getDiagnostics().mosaic.decodeMs, 'number'); + h.display.clear(); +}); + +test('no host cancels a globe stage and refuses work; a restored host clears the status', async () => { + const h = displayHarness(); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + const [radar] = h.layers(); + h.hold(); + const incoming = h.display.setFrame(snapshot, times[1]); + h.setSurface('none'); + h.display.rehome(); + assert.equal(await incoming, false); + assert.equal(h.stages[0].signal.aborted, true); + assert.equal(await h.display.setFrame(snapshot, times[2]), false); + assert.equal(h.display.getDiagnostics().error, NO_IMAGERY_HOST); + assert.equal(h.display.getDiagnostics().time, times[0]); + h.setSurface('globe'); + h.display.rehome(); + assert.deepEqual( + h.layers().map(({ id }) => id), + [radar.id], + ); + assert.equal(h.display.getDiagnostics().error, null); + h.display.clear(); + assert.deepEqual(h.layers(), []); +}); + +const shellDecode = async () => ({ width: 4096, height: 2048, close() {} }); + +test('on 3D tiles the frame is a raised shell and nothing drapes', async () => { + const h = displayHarness({ decodeImage: shellDecode }); + h.setSurface('tiles'); + const stage = h.display.setFrame(snapshot, times[0]); + assert.equal(h.display.getDiagnostics().loading, true); + assert.equal(await stage, true); + const [shell] = h.layers(); + assert.equal(h.layers().length, 1); + assert.equal(shell.raise, 6_200); + const diagnostics = h.display.getDiagnostics(); + assert.equal(diagnostics.host, 'shell'); + assert.equal(diagnostics.height, 6_200); + assert.equal(diagnostics.time, times[0]); + assert.equal(diagnostics.error, null); + assert.equal( + h.moveEnd.numberOfListeners, + 1, + 'one camera listener, for the detail window only', + ); + assert.equal(await h.display.prefetch(snapshot, times[1]), true); + assert.equal(h.display.getDiagnostics().cache.mosaics, 2); + h.display.clear(); + assert.deepEqual(h.layers(), []); + assert.equal(h.moveEnd.numberOfListeners, 0); + assert.equal(h.display.getDiagnostics().cache.mosaics, 0); +}); + +test('a switch to 3D Tiles windows the current view; the globe host never requests a window', async () => { + const urls = []; + const h = displayHarness({ + decodeImage: shellDecode, + fetchImpl: async (url) => { + urls.push(url); + return mockResponse(); + }, + }); + h.engine.fake.script('viewRectangle', view(-100, 35, -98, 36)); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + assert.equal(await h.display.prefetch(snapshot, times[1]), true); + h.moveEnd.raiseEvent(); + await flush(); + assert.equal(h.layers()[0].raise, null); + assert.equal(h.moveEnd.numberOfListeners, 0); + assert.equal(h.display.getDiagnostics().shell, undefined); + assert.ok(urls.length > 0); + assert.ok(urls.every((url) => !url.includes('bbox='))); + + h.setSurface('tiles'); + assert.equal(h.display.rehome(), true); + await flush(); + await flush(); + const detail = urls.filter((url) => url.includes('bbox=')); + assert.deepEqual( + detail.map((url) => + new URL(url, 'https://example.test').searchParams.get('bbox'), + ), + ['-102,34,-96,37'], + ); + const diagnostics = h.display.getDiagnostics(); + assert.equal(diagnostics.time, times[0]); + assert.equal(diagnostics.shell.detail.ready, true); + assert.equal(h.layers().length, 1, 'the detail draws on the shell layer'); + assert.ok(h.layers()[0].detail); + h.setSurface('globe'); + assert.equal(h.display.rehome(), true); + assert.ok( + h.layers().every(({ raise }) => raise === null), + 'the shell layer goes with the host', + ); + await flush(); + assert.equal(h.display.getDiagnostics().host, 'globe'); + assert.equal(h.display.getDiagnostics().loading, false); + assert.equal(h.moveEnd.numberOfListeners, 0); + assert.equal(urls.filter((url) => url.includes('bbox=')).length, 1); + h.display.clear(); +}); + +test('host switches tear one renderer down and restage the retained frame on the other', async () => { + const h = displayHarness(); + const clouds = { ...snapshot, product: 'clouds' }; + h.display.setAlpha(0.4); + assert.equal( + await h.display.setFrame(clouds, times[1], { infrared: 'full' }), + true, + ); + const [draped] = h.layers(); + h.setSurface('tiles'); + assert.equal(h.display.rehome(), true); + assert.equal(h.engine.fake.imageryLayer(draped.id), undefined); + assert.equal( + h.display.getDiagnostics().time, + times[1], + 'the retained time is reported while restaging', + ); + assert.equal(h.display.getDiagnostics().loading, true); + await flush(); + let diagnostics = h.display.getDiagnostics(); + assert.equal(diagnostics.host, 'shell'); + assert.equal(diagnostics.height, 5_500); + assert.equal(diagnostics.loading, false); + assert.equal(diagnostics.time, times[1]); + assert.equal(diagnostics.infrared, 'full'); + const [shell] = h.layers(); + assert.equal(shell.raise, 5_500); + assert.equal(shell.alpha, 0.4); + h.setSurface('globe'); + assert.equal(h.display.rehome(), true); + assert.equal(h.engine.fake.imageryLayer(shell.id), undefined); + assert.equal(h.display.getDiagnostics().time, times[1]); + await flush(); + diagnostics = h.display.getDiagnostics(); + assert.equal(diagnostics.host, 'globe'); + assert.equal(diagnostics.loading, false); + assert.equal(diagnostics.time, times[1]); + assert.equal(diagnostics.infrared, 'full'); + assert.equal(h.layers().length, 1); + assert.equal(h.layers()[0].raise, null); + assert.equal(h.layers()[0].alpha, 0.4); + h.display.clear(); + assert.deepEqual(h.layers(), []); +}); + +test('a host without imagery keeps the active shell and its frame', async () => { + let fetches = 0; + const h = displayHarness({ + decodeImage: shellDecode, + fetchImpl: async () => { + fetches++; + return mockResponse(); + }, + }); + h.setSurface('tiles'); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + const [shell] = h.layers(); + h.setSurface('none'); + h.display.rehome(); + assert.equal(h.engine.fake.imageryLayer(shell.id).shown, false); + assert.equal(h.display.getDiagnostics().time, times[0]); + assert.equal(h.display.getDiagnostics().error, NO_IMAGERY_HOST); + assert.equal(await h.display.setFrame(snapshot, times[1]), false); + h.setSurface('tiles'); + h.display.rehome(); + assert.equal(h.engine.fake.imageryLayer(shell.id).shown, true); + assert.deepEqual( + h.layers().map(({ id }) => id), + [shell.id], + ); + assert.equal(fetches, 1); + assert.equal(h.display.getDiagnostics().error, null); + h.display.setHidden(true); + assert.equal(h.engine.fake.imageryLayer(shell.id).shown, false); + assert.equal(h.display.getDiagnostics().time, null); + h.display.setHidden(false); + assert.equal(h.engine.fake.imageryLayer(shell.id).shown, true); + h.display.clear(); + assert.deepEqual(h.layers(), []); +}); + +test('each product owns its shell height; a product change replaces the shell', async () => { + let width = 2048; + const h = displayHarness({ + decodeImage: async () => ({ width, height: width / 2, close() {} }), + }); + h.setSurface('tiles'); + assert.equal( + await h.display.setFrame({ ...snapshot, product: 'clouds' }, times[0]), + true, + ); + const [first] = h.layers(); + assert.equal(h.display.getDiagnostics().height, 5_500); + width = 4096; + const lightning = h.display.setFrame( + { ...snapshot, product: 'lightning' }, + times[0], + ); + assert.equal(h.engine.fake.imageryLayer(first.id), undefined); + assert.equal(await lightning, true); + assert.equal(h.display.getDiagnostics().height, 6_600); + assert.equal(h.display.getDiagnostics().product, 'lightning'); + assert.equal(h.layers().length, 1); + assert.equal(h.layers()[0].raise, 6_600); + h.display.clear(); +}); + +test('a stage without a current frame is cancelled on host change and detached frames are destroyed', async () => { + const h = displayHarness(); + h.setSurface('tiles'); + const first = h.display.setFrame(snapshot, times[0]); + h.setSurface('globe'); + h.display.rehome(); + assert.equal(await first, false); + assert.deepEqual(h.layers(), []); + assert.equal(await h.display.setFrame(snapshot, times[1]), true); + assert.equal(h.layers().length, 1); + h.setSurface('none'); + h.display.rehome(); + h.display.clear(); + assert.deepEqual(h.layers(), []); +}); + +for (const outcome of [ + 'fetch failure', + 'decode failure', + 'timeout', + 'abort', + 'supersede', + 'rehome', +]) { + test(`mosaic ${outcome} retains the displayed frame and never installs a late decode`, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const decode = deferred(); + let signal, + closed = 0; + const h = displayHarness({ + fetchImpl: async (_url, options) => { + signal = options.signal; + if (outcome === 'fetch failure') throw new Error('offline'); + return mockResponse(); + }, + decodeImage: () => decode.promise, + }); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + const [original] = h.layers(); + const controller = new AbortController(); + const pending = h.display.setFrame( + { ...snapshot, product: 'clouds' }, + times[1], + { signal: controller.signal }, + ); + await flush(); + assert.deepEqual(h.layers(), [original]); + if (outcome === 'decode failure') decode.reject(new Error('bad png')); + if (outcome === 'timeout') t.mock.timers.tick(25_000); + if (outcome === 'abort') controller.abort(); + if (outcome === 'supersede') + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + if (outcome === 'rehome') { + h.setSurface('none'); + h.display.rehome(); + } + assert.equal(await pending, false); + assert.equal(signal.aborted, true); + assert.equal(h.display.getDiagnostics().time, times[0]); + assert.equal(h.engine.fake.imageryLayer(original.id).alpha, 0.7); + if (outcome !== 'decode failure') { + decode.resolve({ + width: 2048, + height: 1024, + close() { + closed++; + }, + }); + await flush(); + if (outcome !== 'fetch failure') assert.equal(closed, 1); + } + assert.deepEqual(h.layers(), [original]); + h.display.clear(); + }); +} + +test('the regional infrared transform masks each tile through the infrared key', async () => { + const h = displayHarness(); + assert.equal( + await h.display.setFrame( + { ...snapshot, product: 'clouds-regional' }, + times[0], + ), + true, + ); + const { transform } = h.layers()[0].source; + const image = { width: 256, height: 256 }; + const processed = transform(image); + assert.equal(processed.source, image); + assert.deepEqual([...processed.pixels], [194, 100, 0, 98]); + h.display.clear(); +}); + +test('infrared mode restages the same time on the globe with its own mosaic', async () => { + let fetches = 0; + const h = displayHarness({ + fetchImpl: async () => { + fetches++; + return mockResponse(); + }, + }); + const clouds = { ...snapshot, product: 'clouds' }; + assert.equal(await h.display.setFrame(clouds, times[0]), true); + const [layer] = h.layers(); + assert.equal(await h.display.setFrame(clouds, times[0]), true); + assert.equal(fetches, 1, 'the same frame and mode is already shown'); + assert.equal( + await h.display.setFrame(clouds, times[0], { infrared: 'full' }), + true, + ); + assert.equal(fetches, 2); + const [full] = h.layers(); + assert.equal(full.id, layer.id); + assert.deepEqual([...full.source.image.pixels], [194, 100, 0, 200]); + assert.equal(full.alpha, 0.7); + assert.equal(h.display.getDiagnostics().infrared, 'full'); + h.display.clear(); +}); + +test('hidden renderer retains the frame without displaying it and rehome cannot reveal it', async () => { + const h = displayHarness(); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + const [layer] = h.layers(); + h.hold(); + const pending = h.display.setFrame(snapshot, times[1]); + h.display.setHidden(true); + assert.equal(await pending, false); + assert.deepEqual( + h.layers().map(({ id }) => id), + [layer.id], + ); + assert.equal(h.layers()[0].shown, false); + assert.match(h.layers()[0].source.url, /20%3A00/); + assert.equal(h.display.getDiagnostics().time, null); + h.display.rehome(); + assert.equal(h.layers()[0].shown, false); + h.display.setHidden(false); + assert.equal(h.layers()[0].shown, true); + assert.equal(h.display.getDiagnostics().time, times[0]); + h.display.clear(); +}); + +const globalSnapshot = { ...snapshot, product: 'clouds' }; +async function showMosaic(h, time, infrared = 'filtered') { + assert.equal( + await h.display.setFrame(globalSnapshot, time, { infrared }), + true, + ); +} + +test('mosaic cache skips fetch and decode, separates modes, evicts LRU at 6 and clears per instance', async (t) => { + let fetches = 0, + decodes = 0; + const h = displayHarness({ + fetchImpl: async () => { + fetches++; + return mockResponse(); + }, + decodeImage: async () => { + decodes++; + return { width: 2048, height: 1024 }; + }, + }); + t.after(() => h.display.clear()); + await showMosaic(h, times[0]); + await showMosaic(h, times[1]); + await showMosaic(h, times[0]); + assert.equal(fetches, 2); + assert.equal(decodes, 2); + assert.deepEqual(h.display.getDiagnostics().mosaic, { + fetched: false, + cached: true, + decodeMs: 0, + }); + await showMosaic(h, times[0], 'full'); + assert.equal(fetches, 3); + assert.equal(h.display.getDiagnostics().mosaic.cached, false); + await showMosaic(h, times[0]); + const extra = Array.from({ length: 4 }, (_, i) => + new Date(Date.parse(times[2]) + i * 3600_000).toISOString(), + ); + for (const time of extra) await showMosaic(h, time); + assert.deepEqual(h.display.getDiagnostics().cache, { + mosaics: 6, + prefetching: false, + }); + await showMosaic(h, times[0]); + assert.equal( + fetches, + 7, + 'touching the oldest frame retained it ahead of the second frame', + ); + await showMosaic(h, times[1]); + assert.equal(fetches, 8, 'least recently used frame was evicted'); + h.display.clear(); + assert.deepEqual(h.display.getDiagnostics().cache, { + mosaics: 0, + prefetching: false, + }); + await showMosaic(h, times[0]); + assert.equal(fetches, 9); + const other = displayHarness({ + fetchImpl: async () => { + fetches++; + return mockResponse(); + }, + }); + t.after(() => other.display.clear()); + await showMosaic(other, times[0]); + assert.equal(fetches, 10, 'instances do not share canvases'); +}); + +test('global prefetch warms a decoded frame without staging imagery and hits on selection', async (t) => { + let fetches = 0; + const h = displayHarness({ + fetchImpl: async () => { + fetches++; + return mockResponse(); + }, + }); + t.after(() => h.display.clear()); + await showMosaic(h, times[0]); + const [layer] = h.layers(); + const warm = h.display.prefetch(globalSnapshot, times[1]); + assert.equal(h.display.getDiagnostics().cache.prefetching, true); + assert.equal(h.display.getDiagnostics().time, times[0]); + assert.equal(await warm, true); + assert.deepEqual(h.layers(), [layer], 'prefetch stages nothing'); + await showMosaic(h, times[1]); + assert.equal(fetches, 2); + assert.equal(h.display.getDiagnostics().mosaic.cached, true); + assert.equal(h.display.getDiagnostics().mosaic.decodeMs, 0); +}); + +test('cancelled or cleared speculative decodes cannot repopulate the cache', async () => { + for (const cancel of ['cancelPrefetch', 'clear', 'setHidden']) { + const decoded = deferred(); + let signal, + closes = 0; + const h = displayHarness({ + fetchImpl: async (_url, options) => { + signal = options.signal; + return mockResponse(); + }, + decodeImage: () => decoded.promise, + }); + const warm = h.display.prefetch(globalSnapshot, times[0]); + await flush(); + h.display[cancel](true); + assert.equal(signal.aborted, true); + decoded.resolve({ + width: 2048, + height: 1024, + close() { + closes++; + }, + }); + assert.equal(await warm, false); + assert.equal(closes, 1); + assert.deepEqual(h.display.getDiagnostics().cache, { + mosaics: 0, + prefetching: false, + }); + h.display.clear(); + } +}); + +test('tiled prefetch covers only visible product bounds at levels 0/1, caps eight requests and drains bodies', async (t) => { + const calls = []; + let bodies = 0; + const h = displayHarness({ + fetchImpl: async (url, { signal }) => { + calls.push({ url: new URL(url, 'http://localhost'), signal }); + return { + ...mockResponse(), + arrayBuffer: async () => { + bodies++; + return new ArrayBuffer(1); + }, + }; + }, + }); + t.after(() => h.display.clear()); + h.engine.fake.script('viewRectangle', view(-120, 25, -100, 50)); + assert.equal(await h.display.prefetch(snapshot, times[1]), true); + assert.equal(calls.length, 2); + assert.equal(bodies, 2); + assert.deepEqual( + calls.map(({ url }) => + ['z', 'x', 'y'].map((key) => url.searchParams.get(key)), + ), + [ + ['0', '0', '0'], + ['1', '0', '0'], + ], + ); + assert.ok( + calls.every(({ url }) => url.searchParams.get('time') === times[1]), + ); + assert.equal( + await h.display.prefetch(snapshot, times[1]), + false, + 'same completed work is not repeated', + ); + h.engine.fake.script('viewRectangle', view(100, 25, 120, 50)); + await h.display.prefetch(snapshot, times[2]); + assert.equal(calls.length, 2, 'view outside product bounds fetches nothing'); + h.engine.fake.script('viewRectangle', view(-180, -90, 180, 90)); + const world = { + ...snapshot, + bounds: { west: -180, south: -90, east: 180, north: 90 }, + }; + await h.display.prefetch(world, times[2]); + assert.equal( + calls.length, + 10, + 'world view is capped at eight additional requests', + ); + assert.equal(bodies, 10); + assert.equal(new Set(calls.slice(2).map(({ url }) => url.href)).size, 8); +}); + +test('prefetch is best effort, aborts on replacement or host suspension, and skips hidden frames', async (t) => { + const signals = []; + const h = displayHarness({ + fetchImpl: (_url, { signal }) => + new Promise((_, reject) => { + signals.push(signal); + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }); + }), + }); + t.after(() => h.display.clear()); + const first = h.display.prefetch(snapshot, times[1]); + assert.ok(signals.length > 0); + assert.equal( + await h.display.setFrame(snapshot, times[0]), + true, + 'current frame never waits for speculative work', + ); + assert.equal(await first, false); + assert.ok(signals.every((signal) => signal.aborted)); + const second = h.display.prefetch(snapshot, times[1]); + h.setSurface('none'); + h.display.rehome(); + assert.equal(await second, false); + const count = signals.length; + assert.equal(await h.display.prefetch(snapshot, times[2]), false); + h.setSurface('globe'); + h.display.setHidden(true); + assert.equal(await h.display.prefetch(snapshot, times[2]), false); + assert.equal(signals.length, count); + assert.equal(h.display.getDiagnostics().cache.prefetching, false); +}); + +test('failed prefetch leaves the current frame and errors unchanged and releases its deadline', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const h = displayHarness({ + fetchImpl: async () => { + throw new Error('offline'); + }, + }); + t.after(() => h.display.clear()); + assert.equal(await h.display.setFrame(snapshot, times[0]), true); + const before = h.display.getDiagnostics(); + assert.equal(await h.display.prefetch(snapshot, times[1]), false); + assert.deepEqual(h.display.getDiagnostics(), before); + t.mock.timers.tick(30_000); + assert.deepEqual(h.display.getDiagnostics(), before); +}); + +for (const product of ['clouds-regional', 'lightning']) { + test(`${product} on the globe host uses 256 px tiles to level 6`, async () => { + const h = displayHarness(); + h.hold(); + const pending = h.display.setFrame({ ...snapshot, product }, times[0]); + const { source } = h.stages[0]; + assert.equal(source.kind, 'tiles'); + assert.equal(source.tileSize, 256); + assert.equal(source.maxLevel, 6); + assert.equal( + new URL(source.url, 'https://example.test').searchParams.get('size'), + '256', + ); + assert.equal(h.display.getDiagnostics().host, 'globe'); + h.display.clear(); + assert.equal(await pending, false); + assert.equal(h.moveEnd.numberOfListeners, 0); + }); +} + function layerHarness({ clock, reducedMotion = false, @@ -482,7 +901,6 @@ function layerHarness({ const stages = []; const motion = target({ matches: reducedMotion }); const documentRef = target({ hidden: false }); - const moveEnd = event(); let time = null; let infraredMode = 'filtered'; let active = null; @@ -540,7 +958,7 @@ function layerHarness({ error: null, }), }; - const viewer = { camera: { moveEnd } }; + const imagery = fakeImagery(); const layer = createWeatherLayer({ clock, id, @@ -548,9 +966,9 @@ function layerHarness({ documentRef, eventTarget, matchMedia: () => motion, - createRendering: () => rendering, + createNativeRendering: () => rendering, }); - layer.init(viewer); + layer.init(null, { engine: imagery.engine }); layer.enable(); return { layer, @@ -559,7 +977,8 @@ function layerHarness({ eventTarget, motion, documentRef, - moveEnd, + moveEnd: imagery.moveEnd, + setSurface: imagery.setSurface, clears: () => clearCount, getAlpha: () => alpha, }; @@ -601,7 +1020,7 @@ test('history has one owned timer, stops while hidden, and releases all work on h.layer.destroy(); assert.equal(h.documentRef.size, 0); assert.equal(h.motion.size, 0); - assert.equal(h.moveEnd.size, 0); + assert.equal(h.moveEnd.numberOfListeners, 0); assert.ok(h.clears() >= 1); }); @@ -664,48 +1083,6 @@ test('disable invalidates an abort-insensitive source result and an already stag h.layer.destroy(); }); -test('external abort cancels a staged renderer request and releases its observer and deadline', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = renderingHarness(); - const controller = new AbortController(); - const stage = h.rendering.setFrame(snapshot, times[0], { - signal: controller.signal, - }); - const tile = deferred(); - h.providers[0].response = tile; - let cancellations = 0; - const requested = h.providers[0].requestImage(0, 0, 0, { - cancel() { - cancellations++; - }, - }); - const before = h.renders(); - controller.abort(); - assert.equal(await stage, false); - assert.equal(cancellations, 1); - assert.equal(h.layers.length, 0); - assert.equal(h.postRender.size, 0); - assert.equal(h.providers[0].errorEvent.size, 0); - assert.ok( - h.renders() > before, - 'cancellation requests a Cesium scheduler update', - ); - tile.resolve({}); - await requested; - t.mock.timers.tick(30_000); - assert.equal(h.rendering.getDiagnostics().time, null); - await assert.rejects( - h.rendering.setFrame(snapshot, times[1], { signal: controller.signal }), - { name: 'AbortError' }, - ); - assert.equal( - h.providers.length, - 1, - 'already-aborted work creates no provider', - ); - h.rendering.clear(); -}); - test('update cancellation reaches imagery after source acquisition has completed', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const h = layerHarness(); @@ -756,51 +1133,6 @@ test('superseding a pending stage preserves the newer manifest loading state', a h.layer.destroy(); }); -test('global infrared is acquired before staging and cropped from one processed mosaic', async () => { - let fetches = 0; - const decode = deferred(); - const h = renderingHarness({ - fetchImpl: async () => { - fetches++; - return mockResponse(); - }, - decodeImage: () => decode.promise, - }); - const task = h.rendering.setFrame( - { ...snapshot, product: 'clouds' }, - times[0], - ); - await flush(); - assert.equal(h.layers.length, 0); - assert.equal(h.rendering.getDiagnostics().loading, true); - assert.deepEqual(h.rendering.getDiagnostics().mosaic, { - fetched: true, - decodeMs: null, - cached: false, - }); - decode.resolve({ width: 2048, height: 1024 }); - await flush(); - const provider = h.layers[0].imageryProvider; - assert.equal(provider.maximumLevel, 3); - assert.equal(provider.tileWidth, 256); - assert.equal(provider.tileHeight, 256); - assert.equal(provider.tilingScheme.getNumberOfXTilesAtLevel(0), 2); - assert.deepEqual(provider.tilingScheme.rectangle, provider.rectangle); - const first = await provider.requestImage(0, 0, 0); - const next = await provider.requestImage(1, 0, 0); - assert.equal(first.source, next.source); - assert.deepEqual( - first.source.pixels, - new Uint8ClampedArray([194, 100, 0, 98]), - ); - assert.equal(fetches, 1); - h.settle(); - assert.equal(await task, true); - assert.equal(h.rendering.getDiagnostics().mosaic.fetched, true); - assert.equal(typeof h.rendering.getDiagnostics().mosaic.decodeMs, 'number'); - h.rendering.clear(); -}); - test('coverage navigation requires the shared shell handoff and detaches cleanly', async () => { const h = layerHarness(); const update = h.layer.update(); @@ -899,353 +1231,6 @@ test('history loading updates an existing info line and preserves summary status h.layer.destroy(); }); -test('no host cancels a globe stage, detaches the layer and restores it in weather order', async () => { - let host; - const h = renderingHarness({ getHost: () => host }); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - const stage = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - assert.equal(await stage, true); - const radar = h.layers[0]; - const cloud = h.viewer.imageryLayers.add({ name: 'cloud' }); - const lightning = h.viewer.imageryLayers.add({ name: 'lightning' }); - orderWeatherImagery(h.viewer.imageryLayers, lightning, 3); - orderWeatherImagery(h.viewer.imageryLayers, cloud, 1); - const incoming = h.rendering.setFrame(snapshot, times[1]); - host = { collection: null, kind: 'none' }; - assert.equal(h.rendering.rehome(), true); - assert.equal(await incoming, false); - assert.deepEqual(h.layers, [cloud, lightning]); - assert.equal(await h.rendering.setFrame(snapshot, times[2]), false); - assert.equal(h.rendering.getDiagnostics().error, NO_IMAGERY_HOST); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - h.rendering.rehome(); - assert.deepEqual(h.layers, [cloud, radar, lightning]); - assert.equal(h.rendering.getDiagnostics().error, null); - h.rendering.clear(); - assert.deepEqual(h.layers, [cloud, lightning]); - assert.equal(radar.destroyed, true); -}); - -const shellDecode = async () => ({ width: 4096, height: 2048, close() {} }); - -test('the tileset host draws a raised shell and drapes nothing, at any camera height', async () => { - const tiles = new ImageryLayerCollection(); - const h = renderingHarness({ - getHost: () => ({ collection: tiles, kind: 'tileset' }), - decodeImage: shellDecode, - }); - h.viewer.camera = { - moveEnd: event(), - positionCartographic: { height: 1200 }, - }; - const stage = h.rendering.setFrame(snapshot, times[0]); - await flush(); - assert.equal(tiles.length, 0); - assert.equal(h.layers.length, 0); - assert.equal(h.providers.length, 0); - assert.equal(h.shells().length, 1); - assert.equal(h.rendering.getDiagnostics().loading, true); - h.renderShells(); - assert.equal(await stage, true); - const diagnostics = h.rendering.getDiagnostics(); - assert.equal(diagnostics.host, 'shell'); - assert.equal(diagnostics.height, 6_200); - assert.equal(diagnostics.time, times[0]); - assert.equal(diagnostics.error, null); - assert.equal( - h.viewer.camera.moveEnd.size, - 1, - 'one camera listener, for the detail window only', - ); - assert.equal(await h.rendering.prefetch(snapshot, times[1]), true); - assert.equal(h.rendering.getDiagnostics().cache.mosaics, 2); - h.rendering.clear(); - assert.equal(h.shells().length, 0); - assert.equal(h.postRender.size, 0); - assert.equal(h.viewer.camera.moveEnd.size, 0); - assert.equal(h.rendering.getDiagnostics().cache.mosaics, 0); -}); - -test('a switch to 3D Tiles windows the current view; the globe host never requests a window', async () => { - let host; - const urls = []; - const h = renderingHarness({ - getHost: () => host, - decodeImage: shellDecode, - fetchImpl: async (url) => { - urls.push(url); - return mockResponse(); - }, - }); - h.viewer.camera = { - moveEnd: event(), - computeViewRectangle: () => Cesium.Rectangle.fromDegrees(-100, 35, -98, 36), - }; - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - const globe = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - assert.equal(await globe, true); - assert.equal(await h.rendering.prefetch(snapshot, times[1]), true); - h.viewer.camera.moveEnd.emit(); - await flush(); - assert.equal(h.shells().length, 0); - assert.equal(h.viewer.camera.moveEnd.size, 0); - assert.equal(h.rendering.getDiagnostics().shell, undefined); - assert.ok(urls.length > 0); - assert.ok(urls.every((url) => !url.includes('bbox='))); - - host = { collection: new ImageryLayerCollection(), kind: 'tileset' }; - assert.equal(h.rendering.rehome(), true); - await flush(); - h.renderShells(); - await flush(); - h.renderShells(); - const detail = urls.filter((url) => url.includes('bbox=')); - assert.deepEqual( - detail.map((url) => - new URL(url, 'https://example.test').searchParams.get('bbox'), - ), - ['-102,34,-96,37'], - ); - const diagnostics = h.rendering.getDiagnostics(); - assert.equal(diagnostics.time, times[0]); - assert.equal(diagnostics.shell.detail.ready, true); - assert.equal(h.shells().length, 1, 'the detail draws on the shell surface'); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - assert.equal(h.rendering.rehome(), true); - assert.equal(h.shells().length, 0, 'the shell surface goes with the host'); - await flush(); - h.settle(); - await flush(); - assert.equal(h.rendering.getDiagnostics().host, 'globe'); - assert.equal(h.rendering.getDiagnostics().loading, false); - assert.equal(h.viewer.camera.moveEnd.size, 0); - assert.equal(urls.filter((url) => url.includes('bbox=')).length, 1); - h.rendering.clear(); -}); - -test('host switches tear one renderer down and restage the retained frame on the other', async () => { - let host; - const tiles = new ImageryLayerCollection(); - const h = renderingHarness({ - getHost: () => host, - decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), - }); - const clouds = { ...snapshot, product: 'clouds' }; - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - h.rendering.setAlpha(0.4); - const first = h.rendering.setFrame(clouds, times[1], { infrared: 'full' }); - await flush(); - h.settle(); - assert.equal(await first, true); - const layer = h.layers[0]; - host = { collection: tiles, kind: 'tileset' }; - assert.equal(h.rendering.rehome(), true); - assert.equal(h.layers.length, 0); - assert.equal(layer.destroyed, true); - assert.equal(tiles.length, 0); - assert.equal( - h.rendering.getDiagnostics().time, - times[1], - 'the retained time is reported while restaging', - ); - assert.equal(h.rendering.getDiagnostics().loading, true); - await flush(); - const [shell] = h.shells(); - h.renderShells(); - await flush(); - let diagnostics = h.rendering.getDiagnostics(); - assert.equal(diagnostics.host, 'shell'); - assert.equal(diagnostics.height, 5_500); - assert.equal(diagnostics.loading, false); - assert.equal(diagnostics.time, times[1]); - assert.equal(diagnostics.infrared, 'full'); - assert.equal(shell.appearance.material.uniforms.alpha, 0.4); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - assert.equal(h.rendering.rehome(), true); - assert.equal(shell.destroyed, true); - assert.equal(shell.appearance.material.destroyed, true); - assert.equal(h.shells().length, 0); - assert.equal(h.rendering.getDiagnostics().time, times[1]); - await flush(); - assert.equal(h.layers.length, 1); - h.settle(); - await flush(); - diagnostics = h.rendering.getDiagnostics(); - assert.equal(diagnostics.host, 'globe'); - assert.equal(diagnostics.loading, false); - assert.equal(diagnostics.time, times[1]); - assert.equal(diagnostics.infrared, 'full'); - assert.equal(h.layers[0].alpha, 0.4); - assert.equal(h.layers[0].imageryProvider.tileWidth, 256); - h.rendering.clear(); - assert.equal(h.layers.length, 0); - assert.equal(h.postRender.size, 0); -}); - -test('a host without imagery keeps the active shell and its frame', async () => { - let host = { collection: new ImageryLayerCollection(), kind: 'tileset' }; - let fetches = 0; - const h = renderingHarness({ - getHost: () => host, - decodeImage: shellDecode, - fetchImpl: async () => { - fetches++; - return mockResponse(); - }, - }); - const stage = h.rendering.setFrame(snapshot, times[0]); - await flush(); - h.renderShells(); - assert.equal(await stage, true); - const [shell] = h.shells(); - host = { collection: null, kind: 'none' }; - h.rendering.rehome(); - assert.equal(shell.show, false); - assert.equal(shell.destroyed, false); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - assert.equal(h.rendering.getDiagnostics().error, NO_IMAGERY_HOST); - assert.equal(await h.rendering.setFrame(snapshot, times[1]), false); - host = { collection: new ImageryLayerCollection(), kind: 'tileset' }; - h.rendering.rehome(); - assert.equal(shell.show, true); - assert.deepEqual(h.shells(), [shell]); - assert.equal(fetches, 1); - assert.equal(h.rendering.getDiagnostics().error, null); - h.rendering.setHidden(true); - assert.equal(shell.show, false); - assert.equal(h.rendering.getDiagnostics().time, null); - h.rendering.setHidden(false); - assert.equal(shell.show, true); - h.rendering.clear(); - assert.equal(h.shells().length, 0); -}); - -test('each product owns its shell height; a product change replaces the shell', async () => { - let width = 2048; - const h = renderingHarness({ - getHost: () => ({ collection: null, kind: 'tileset' }), - decodeImage: async () => ({ width, height: width / 2, close() {} }), - }); - const global = h.rendering.setFrame( - { ...snapshot, product: 'clouds' }, - times[0], - ); - await flush(); - h.renderShells(); - assert.equal(await global, true); - const [first] = h.shells(); - assert.equal(h.rendering.getDiagnostics().height, 5_500); - width = 4096; - const lightning = h.rendering.setFrame( - { ...snapshot, product: 'lightning' }, - times[0], - ); - assert.equal(first.destroyed, true); - await flush(); - h.renderShells(); - assert.equal(await lightning, true); - assert.equal(h.rendering.getDiagnostics().height, 6_600); - assert.equal(h.rendering.getDiagnostics().product, 'lightning'); - assert.equal(h.shells().length, 1); - h.rendering.clear(); -}); - -test('a stage without a current frame is cancelled on host change and detached frames are destroyed', async () => { - const tiles = new ImageryLayerCollection(); - let host = { collection: tiles, kind: 'tileset' }; - const h = renderingHarness({ getHost: () => host }); - const first = h.rendering.setFrame(snapshot, times[0]); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - h.rendering.rehome(); - assert.equal(await first, false); - assert.equal(tiles.length, 0); - const ready = h.rendering.setFrame(snapshot, times[1]); - h.settle(); - await ready; - const layer = h.layers[0]; - let destroyed = false; - layer.destroy = () => { - destroyed = true; - }; - host = { collection: null, kind: 'none' }; - h.rendering.rehome(); - h.rendering.clear(); - assert.equal(destroyed, true); -}); - -for (const product of ['clouds', 'clouds-regional', 'radar', 'lightning']) { - test(`${product} does not use a hard layer alpha cut on the globe host`, async () => { - const collection = new ImageryLayerCollection(); - const h = renderingHarness({ - getHost: () => ({ collection, kind: 'globe' }), - }); - const stage = h.rendering.setFrame({ ...snapshot, product }, times[0]); - await flush(); - const layer = collection.get(0); - assert.equal(layer.colorToAlpha, undefined); - assert.equal(layer.alpha, 0); - h.rendering.clear(); - assert.equal(await stage, false); - }); -} - -for (const statusCode of [429, 503]) { - test(`${statusCode} retries up to three times without failing a healthy frame`, async () => { - let now = 0; - const h = renderingHarness({ now: () => now }); - h.viewer.scene.globe.tilesLoaded = false; - const stage = h.rendering.setFrame(snapshot, times[0]); - h.providers[0].response = deferred(); - const rejected = h.providers[0].requestImage(0, 0, 0, {}); - h.providers[0].response.reject(new Error('throttled')); - await assert.rejects(rejected, /throttled/); - for (let timesRetried = 0; timesRetried < 3; timesRetried++) { - const error = { - error: { statusCode }, - x: 0, - y: 0, - level: 0, - timesRetried, - retry: false, - }; - h.providers[0].errorEvent.emit(error); - assert.equal(error.retry, true); - assert.equal(h.rendering.getDiagnostics().error, null); - } - h.settle(); - assert.equal(h.rendering.getDiagnostics().loading, true); - h.providers[0].response = deferred(); - const retried = h.providers[0].requestImage(0, 0, 0, {}); - h.providers[0].response.resolve({}); - await retried; - now = 250; - h.settle(); - assert.equal(await stage, true); - const exhausted = h.rendering.setFrame(snapshot, times[1]); - const error = { error: { statusCode }, timesRetried: 3, retry: false }; - for (let i = 0; i < 4; i++) h.providers[1].errorEvent.emit(error); - h.settle(); - assert.equal(error.retry, false); - assert.equal(await exhausted, false); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - h.rendering.clear(); - }); -} - -test('404 fails a frame without retry', async () => { - const h = renderingHarness(); - const stage = h.rendering.setFrame(snapshot, times[0]); - const error = { error: { statusCode: 404 }, timesRetried: 0, retry: false }; - h.providers[0].errorEvent.emit(error); - h.settle(); - assert.equal(error.retry, false); - assert.equal(await stage, false); - h.rendering.clear(); -}); - test('no host pauses history, refreshes metadata, and restores retained time and playback', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); let refreshes = 0; @@ -1257,8 +1242,8 @@ test('no host pauses history, refreshes metadata, and restores retained time and }, }, }); - let host = { collection: {}, kind: 'tileset' }; - h.layer.attachShellServices({ imageryHost: () => host }); + h.setSurface('tiles'); + h.eventTarget.emit('gev:map-stack-changed'); const update = h.layer.update(); await flush(); h.stages[0].finish(); @@ -1266,7 +1251,7 @@ test('no host pauses history, refreshes metadata, and restores retained time and h.layer.setParams({ play: true }); t.mock.timers.tick(2000); assert.equal(h.stages.length, 2); - host = { collection: null, kind: 'none' }; + h.setSurface('none'); h.eventTarget.emit('gev:map-stack-changed'); await flush(); assert.equal(h.layer.getDiagnostics().playing, false); @@ -1278,7 +1263,7 @@ test('no host pauses history, refreshes metadata, and restores retained time and t.mock.timers.tick(10_000); assert.equal(h.stages.length, 2); assert.equal(h.layer.getDiagnostics().time, times[2]); - host = { collection: {}, kind: 'globe' }; + h.setSurface('globe'); h.eventTarget.emit('gev:map-stack-changed'); assert.equal(h.layer.getDiagnostics().playing, true); assert.equal(h.layer.getDiagnostics().timerActive, true); @@ -1291,11 +1276,10 @@ test('no host pauses history, refreshes metadata, and restores retained time and test('host restore through update stages latest after a no-host start', async () => { const h = layerHarness(); - let host = { collection: null, kind: 'none' }; - h.layer.attachShellServices({ imageryHost: () => host }); + h.setSurface('none'); await h.layer.update(); assert.equal(h.stages.length, 0); - host = { collection: {}, kind: 'tileset' }; + h.setSurface('tiles'); const update = h.layer.update(); await flush(); assert.equal(h.stages[0].time, snapshot.latest); @@ -1305,333 +1289,6 @@ test('host restore through update stages latest after a no-host start', async () h.layer.destroy(); }); -test('real Cesium global infrared provider supports the globe host', async () => { - const collection = new Cesium.ImageryLayerCollection(); - const h = renderingHarness({ - cesium: Cesium, - getHost: () => ({ collection, kind: 'globe' }), - }); - const stage = h.rendering.setFrame( - { - ...snapshot, - product: 'clouds', - bounds: { west: -180, south: -60, east: 180, north: 60 }, - }, - times[0], - ); - await flush(); - const layer = collection.get(0); - const provider = layer.imageryProvider; - assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme); - assert.equal(provider.maximumLevel, 3); - assert.equal(provider.tileWidth, 256); - assert.equal(provider.tileHeight, 256); - assert.equal(provider.tilingScheme.getNumberOfXTilesAtLevel(0), 2); - assert.equal(provider.tilingScheme.getNumberOfYTilesAtLevel(0), 1); - assert.ok( - Cesium.Rectangle.equals( - provider.rectangle, - Cesium.Rectangle.fromDegrees(-180, -60, 180, 60), - ), - ); - assert.equal(layer.colorToAlpha, undefined); - assert.ok(provider.requestImage(0, 0, 0) instanceof Promise); - h.rendering.clear(); - assert.equal(await stage, false); -}); - -for (const statusCode of [429, 503]) { - test(`${statusCode} retries belong to tiles, regardless of the layer-wide counter`, async () => { - const h = renderingHarness(); - const stage = h.rendering.setFrame(snapshot, times[0]); - const provider = h.providers[0]; - const fail = (x, timesRetried = 0) => { - const error = { x, y: 0, level: 2, timesRetried, error: { statusCode } }; - provider.errorEvent.emit(error); - return error.retry; - }; - // A shared counter may exceed three even though these are first failures. - for (let x = 0; x < 4; x++) assert.equal(fail(x, x + 4), true); - provider.response = { promise: Promise.resolve({}) }; - await provider.requestImage(0, 0, 2); - for (let i = 0; i < 3; i++) - assert.equal(fail(0), true, 'own success reset the count'); - for (let i = 0; i < 2; i++) { - await provider.requestImage(2, 0, 2); - assert.equal(fail(1), true); - } - await provider.requestImage(3, 0, 2); - assert.equal( - fail(1), - false, - 'four failures exhaust this tile despite other successes', - ); - h.settle(); - assert.equal(await stage, false); - assert.equal(provider.errorEvent.size, 0); - h.rendering.clear(); - }); -} - -for (const [id, product] of [ - ['weather-radar', 'radar'], - ['weather-satellite', 'clouds-regional'], - ['weather-satellite', 'clouds'], - ['weather-lightning', 'lightning'], -]) { - test(`${product} shows on 3D Tiles at street level and keeps its history through host changes`, async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - t.mock.method(Date, 'now', () => Date.parse(times[2])); - const size = - product === 'clouds' - ? { width: 2048, height: 1024 } - : { width: 4096, height: 2048 }; - let host; - const h = renderingHarness({ - getHost: () => host, - decodeImage: async () => ({ ...size, close() {} }), - }); - host = { collection: new ImageryLayerCollection(), kind: 'tileset' }; - const camera = { - moveEnd: event(), - positionCartographic: { height: 1200 }, - }; - h.viewer.camera = camera; - const events = target(); - const layer = createWeatherLayer({ - id, - feed: { getSnapshot: async () => ({ ...snapshot, product }) }, - createRendering: () => h.rendering, - documentRef: target({ hidden: false }), - eventTarget: events, - matchMedia: () => target({ matches: false }), - }); - layer.init(h.viewer); - layer.attachShellServices({ imageryHost: () => host }); - layer.setParams({ product }); - layer.enable(); - const update = layer.update(); - await flush(); - h.renderShells(); - await update; - assert.equal(layer.getDiagnostics().time, times[2]); - assert.equal(layer.getDiagnostics().host, 'shell'); - assert.equal(layer.getRowControls().summary.status, null); - assert.doesNotMatch(layer.getRowControls().info, /Hidden/); - const [shell] = h.shells(); - assert.equal(shell.show, true); - - layer.setParams({ play: true }); - t.mock.timers.tick(2000); - await flush(); - assert.equal( - layer.getDiagnostics().time, - times[0], - 'history advances at street level', - ); - camera.positionCartographic.height = 59_999; - camera.moveEnd.emit(); - assert.equal(shell.show, true, 'no height gate'); - assert.equal(layer.getDiagnostics().playing, true); - assert.equal(layer.getDiagnostics().timerActive, true); - - host = { collection: null, kind: 'none' }; - events.emit('gev:map-stack-changed'); - assert.equal(shell.show, false); - assert.equal(layer.getRowControls().summary.status, NO_IMAGERY_HOST); - assert.equal(layer.getDiagnostics().timerActive, false); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - events.emit('gev:map-stack-changed'); - assert.equal(shell.destroyed, true); - assert.equal( - layer.getDiagnostics().time, - times[0], - 'the switch restages the shown history frame', - ); - await flush(); - h.settle(); - await flush(); - assert.equal(layer.getDiagnostics().host, 'globe'); - assert.equal(layer.getDiagnostics().time, times[0]); - assert.equal(layer.getDiagnostics().loading, false); - layer.destroy(); - assert.equal(camera.moveEnd.size, 0); - assert.equal(events.size, 0); - assert.equal(h.shells().length, 0); - assert.equal(h.postRender.size, 0); - }); -} - -for (const outcome of [ - 'fetch failure', - 'decode failure', - 'timeout', - 'abort', - 'supersede', - 'rehome', -]) { - test(`mosaic ${outcome} retains the displayed frame and never installs a late decode`, async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const decode = deferred(); - let signal, - closed = 0, - host; - const h = renderingHarness({ - getHost: () => host, - fetchImpl: async (_url, options) => { - signal = options.signal; - if (outcome === 'fetch failure') throw new Error('offline'); - return mockResponse(); - }, - decodeImage: () => decode.promise, - }); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - const first = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - await first; - const original = h.layers[0]; - const controller = new AbortController(); - const pending = h.rendering.setFrame( - { ...snapshot, product: 'clouds' }, - times[1], - { signal: controller.signal }, - ); - await flush(); - assert.deepEqual(h.layers, [original]); - if (outcome === 'decode failure') decode.reject(new Error('bad png')); - if (outcome === 'timeout') t.mock.timers.tick(25_000); - if (outcome === 'abort') controller.abort(); - if (outcome === 'supersede') - assert.equal(await h.rendering.setFrame(snapshot, times[0]), true); - if (outcome === 'rehome') { - host = { - collection: renderingHarness().viewer.imageryLayers, - kind: 'globe', - }; - h.rendering.rehome(); - } - assert.equal(await pending, false); - assert.equal(signal.aborted, true); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - assert.equal(original.alpha, 0.7); - if (outcome !== 'decode failure') { - decode.resolve({ - width: 2048, - height: 1024, - close() { - closed++; - }, - }); - await flush(); - if (outcome !== 'fetch failure') assert.equal(closed, 1); - } - assert.equal(host.collection.length, 1); - h.rendering.clear(); - assert.equal(h.postRender.size, 0); - }); -} - -test('regional transfer preserves deferrals, original Request, rejection and cancellation', async () => { - const h = renderingHarness(); - const stage = h.rendering.setFrame( - { ...snapshot, product: 'clouds-regional' }, - times[0], - ); - const provider = h.providers[0]; - let cancelled = 0; - const request = { - cancel() { - cancelled++; - }, - }; - assert.equal(provider.requestImage(1, 2, 3, request), undefined); - assert.deepEqual(provider.requestArgs, [1, 2, 3, request]); - const image = { width: 256, height: 256 }; - provider.response = { promise: Promise.resolve(image) }; - const processed = await provider.requestImage(1, 2, 3, request); - assert.equal(processed.source, image); - assert.deepEqual([...processed.pixels], [194, 100, 0, 98]); - const error = new Error('cancelled upstream'); - provider.response = { promise: Promise.reject(error) }; - await assert.rejects( - provider.requestImage(0, 0, 0, request), - (cause) => cause === error, - ); - provider.response = deferred(); - const late = provider.requestImage(0, 0, 0, request); - h.rendering.clear(); - assert.equal(cancelled, 1); - provider.response.resolve(image); - assert.equal(await late, image, 'closed frames do not process late images'); - assert.equal(await stage, false); -}); - -test('regional tiles decoded as flipped ImageBitmaps are drawn back upright', async () => { - const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap'); - const previous = globalThis.ImageBitmap; - globalThis.ImageBitmap = class ImageBitmap { - width = 256; - height = 256; - }; - try { - const h = renderingHarness(); - void h.rendering.setFrame( - { ...snapshot, product: 'clouds-regional' }, - times[0], - ); - const provider = h.providers[0]; - // Cesium decodes with imageOrientation 'flipY'; the canvas upload flips again. - provider.response = { promise: Promise.resolve(new ImageBitmap()) }; - const bitmapTile = await provider.requestImage(0, 0, 0, {}); - assert.deepEqual(bitmapTile.drawnWith, [1, 0, 0, -1, 0, 256]); - // An HTMLImageElement fallback is not pre-flipped. - provider.response = { - promise: Promise.resolve({ width: 256, height: 256 }), - }; - const imageTile = await provider.requestImage(1, 0, 0, {}); - assert.equal(imageTile.drawnWith, null); - h.rendering.clear(); - } finally { - if (hadBitmap) globalThis.ImageBitmap = previous; - else delete globalThis.ImageBitmap; - } -}); - -for (const kind of ['globe']) { - test(`infrared mode restages the same time on ${kind}, retaining the old layer for one rendered frame`, async () => { - let host, - now = 0; - const h = renderingHarness({ getHost: () => host, now: () => now }); - host = { collection: h.viewer.imageryLayers, kind }; - const clouds = { ...snapshot, product: 'clouds' }; - const first = h.rendering.setFrame(clouds, times[0]); - await flush(); - await h.providers[0].requestImage(0, 0, 0); - now = 250; - h.settle(); - await first; - const previous = h.layers[0]; - assert.equal(await h.rendering.setFrame(clouds, times[0]), true); - assert.equal(h.providers.length, 1); - const full = h.rendering.setFrame(clouds, times[0], { infrared: 'full' }); - await flush(); - const tile = await h.providers[1].requestImage(0, 0, 0); - assert.deepEqual([...tile.source.pixels], [194, 100, 0, 200]); - now = 500; - h.settle(); - assert.equal(await full, true); - assert.equal(h.layers.length, 2); - assert.equal(h.layers[1].alpha, 0.7); - assert.equal(previous.destroyed, undefined); - assert.equal(h.rendering.getDiagnostics().infrared, 'full'); - h.postRender.emit(); - assert.equal(h.layers.length, 1); - assert.equal(previous.destroyed, true); - h.rendering.clear(); - assert.equal(h.postRender.size, 0); - }); -} - test('satellite mode chips restage history without changing time or latest-follow intent', async () => { const h = layerHarness({ id: 'weather-satellite' }); const update = h.layer.update(); @@ -1710,61 +1367,6 @@ test('satellite cloud filter spans Full to Clouds only without changing layer op h.layer.destroy(); }); -test('a rapid third selection waits for retirement before installing, and clear cancels that wait', async () => { - for (const clear of [false, true]) { - const h = renderingHarness(); - const first = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - await first; - const second = h.rendering.setFrame(snapshot, times[1]); - h.settle(); - await second; - assert.equal(h.layers.length, 2); - const third = h.rendering.setFrame(snapshot, times[2]); - assert.equal(h.layers.length, 2); - assert.equal( - h.providers.length, - 2, - 'third provider waits for outgoing retirement', - ); - if (clear) { - h.rendering.clear(); - assert.equal(await third, false); - h.postRender.emit(); - assert.equal(h.layers.length, 0); - assert.equal(h.postRender.size, 0); - } else { - h.postRender.emit(); - assert.equal(h.layers.length, 2); - assert.equal(h.providers.length, 3); - h.settle(); - assert.equal(await third, true); - h.postRender.emit(); - assert.equal(h.layers.length, 1); - h.rendering.clear(); - } - } -}); - -test('a delayed installation failure retains the replacement and does not throw from postRender', async () => { - const h = renderingHarness(); - for (const time of times.slice(0, 2)) { - const stage = h.rendering.setFrame(snapshot, time); - h.settle(); - assert.equal(await stage, true); - } - const third = h.rendering.setFrame(snapshot, times[2]); - h.viewer.imageryLayers.addImageryProvider = () => { - throw new Error('upload unavailable'); - }; - assert.doesNotThrow(() => h.postRender.emit()); - assert.equal(await third, false); - assert.equal(h.layers.length, 1); - assert.equal(h.layers[0].alpha, 0.7); - assert.equal(h.rendering.getDiagnostics().time, times[1]); - h.rendering.clear(); -}); - test('one row steps every registered observation; missing frames hide and latest restores each product', async (t) => { const clock = createWeatherClock(); const radar = layerHarness({ clock }); @@ -1886,27 +1488,6 @@ test('history manifest expiry hides imagery without jumping to latest; host resu assert.equal(h.layer.getDiagnostics().time, times[0]); }); -test('hidden renderer retains the frame without displaying it and rehome cannot reveal it', async () => { - const h = renderingHarness(); - const first = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - await first; - const layer = h.layers[0]; - const pending = h.rendering.setFrame(snapshot, times[1]); - h.rendering.setHidden(true); - assert.equal(await pending, false); - assert.equal(h.layers.length, 1); - assert.equal(h.layers[0], layer); - assert.equal(layer.show, false); - assert.equal(h.rendering.getDiagnostics().time, null); - h.rendering.rehome(); - assert.equal(layer.show, false); - h.rendering.setHidden(false); - assert.equal(layer.show, true); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - h.rendering.clear(); -}); - test('satellite global selection uses the three-hour gap and preserves the shared target on product changes', async (t) => { const clock = createWeatherClock(); const earlier = '2026-09-15T18:00:00.000Z'; @@ -2033,246 +1614,6 @@ test('observed descriptors keep configuration only and label satellite clouds by } }); -const globalSnapshot = { ...snapshot, product: 'clouds' }; -async function showMosaic(h, time, infrared = 'filtered') { - const pending = h.rendering.setFrame(globalSnapshot, time, { infrared }); - await flush(); - h.settle(); - assert.equal(await pending, true); - h.settle(); -} - -test('mosaic cache skips fetch and decode, separates modes, evicts LRU at 6 and clears per instance', async (t) => { - let fetches = 0, - decodes = 0; - const h = renderingHarness({ - fetchImpl: async () => { - fetches++; - return mockResponse(); - }, - decodeImage: async () => { - decodes++; - return { width: 2048, height: 1024 }; - }, - }); - t.after(() => h.rendering.clear()); - await showMosaic(h, times[0]); - await showMosaic(h, times[1]); - await showMosaic(h, times[0]); - assert.equal(fetches, 2); - assert.equal(decodes, 2); - assert.deepEqual(h.rendering.getDiagnostics().mosaic, { - fetched: false, - cached: true, - decodeMs: 0, - }); - await showMosaic(h, times[0], 'full'); - assert.equal(fetches, 3); - assert.equal(h.rendering.getDiagnostics().mosaic.cached, false); - await showMosaic(h, times[0]); - const extra = Array.from({ length: 4 }, (_, i) => - new Date(Date.parse(times[2]) + i * 3600_000).toISOString(), - ); - for (const time of extra) await showMosaic(h, time); - assert.deepEqual(h.rendering.getDiagnostics().cache, { - mosaics: 6, - prefetching: false, - }); - await showMosaic(h, times[0]); - assert.equal( - fetches, - 7, - 'touching the oldest frame retained it ahead of the second frame', - ); - await showMosaic(h, times[1]); - assert.equal(fetches, 8, 'least recently used frame was evicted'); - h.rendering.clear(); - assert.deepEqual(h.rendering.getDiagnostics().cache, { - mosaics: 0, - prefetching: false, - }); - await showMosaic(h, times[0]); - assert.equal(fetches, 9); - const other = renderingHarness({ - fetchImpl: async () => { - fetches++; - return mockResponse(); - }, - }); - t.after(() => other.rendering.clear()); - await showMosaic(other, times[0]); - assert.equal(fetches, 10, 'instances do not share canvases'); -}); - -test('global prefetch warms a decoded frame without staging imagery and hits on selection', async (t) => { - let fetches = 0; - const h = renderingHarness({ - fetchImpl: async () => { - fetches++; - return mockResponse(); - }, - }); - t.after(() => h.rendering.clear()); - await showMosaic(h, times[0]); - const warm = h.rendering.prefetch(globalSnapshot, times[1]); - assert.equal(h.rendering.getDiagnostics().cache.prefetching, true); - assert.equal(h.rendering.getDiagnostics().time, times[0]); - assert.equal(h.layers.length, 1); - assert.equal(await warm, true); - await showMosaic(h, times[1]); - assert.equal(fetches, 2); - assert.equal(h.rendering.getDiagnostics().mosaic.cached, true); - assert.equal(h.rendering.getDiagnostics().mosaic.decodeMs, 0); -}); - -test('cancelled or cleared speculative decodes cannot repopulate the cache', async () => { - for (const cancel of ['cancelPrefetch', 'clear', 'setHidden']) { - const decoded = deferred(); - let signal, - closes = 0; - const h = renderingHarness({ - fetchImpl: async (_url, options) => { - signal = options.signal; - return mockResponse(); - }, - decodeImage: () => decoded.promise, - }); - const warm = h.rendering.prefetch(globalSnapshot, times[0]); - await flush(); - h.rendering[cancel](true); - assert.equal(signal.aborted, true); - decoded.resolve({ - width: 2048, - height: 1024, - close() { - closes++; - }, - }); - assert.equal(await warm, false); - assert.equal(closes, 1); - assert.deepEqual(h.rendering.getDiagnostics().cache, { - mosaics: 0, - prefetching: false, - }); - h.rendering.clear(); - } -}); - -test('tiled prefetch covers only visible product bounds at levels 0/1, caps eight requests and drains bodies', async (t) => { - const calls = []; - let bodies = 0; - const h = renderingHarness({ - fetchImpl: async (url, { signal }) => { - calls.push({ url: new URL(url, 'http://localhost'), signal }); - return { - ...mockResponse(), - arrayBuffer: async () => { - bodies++; - return new ArrayBuffer(1); - }, - }; - }, - }); - t.after(() => h.rendering.clear()); - h.viewer.camera = { - computeViewRectangle: () => - Cesium.Rectangle.fromDegrees(-120, 25, -100, 50), - }; - assert.equal(await h.rendering.prefetch(snapshot, times[1]), true); - assert.equal(calls.length, 2); - assert.equal(bodies, 2); - assert.deepEqual( - calls.map(({ url }) => - ['z', 'x', 'y'].map((key) => url.searchParams.get(key)), - ), - [ - ['0', '0', '0'], - ['1', '0', '0'], - ], - ); - assert.ok( - calls.every(({ url }) => url.searchParams.get('time') === times[1]), - ); - assert.equal( - await h.rendering.prefetch(snapshot, times[1]), - false, - 'same completed work is not repeated', - ); - h.viewer.camera.computeViewRectangle = () => - Cesium.Rectangle.fromDegrees(100, 25, 120, 50); - await h.rendering.prefetch(snapshot, times[2]); - assert.equal(calls.length, 2, 'view outside product bounds fetches nothing'); - h.viewer.camera.computeViewRectangle = () => Cesium.Rectangle.MAX_VALUE; - const world = { - ...snapshot, - bounds: { west: -180, south: -90, east: 180, north: 90 }, - }; - await h.rendering.prefetch(world, times[2]); - assert.equal( - calls.length, - 10, - 'world view is capped at eight additional requests', - ); - assert.equal(bodies, 10); - assert.equal(new Set(calls.slice(2).map(({ url }) => url.href)).size, 8); -}); - -test('prefetch is best effort, aborts on replacement or host suspension, and skips hidden frames', async (t) => { - let host = { collection: null, kind: 'globe' }; - const signals = []; - const h = renderingHarness({ - getHost: () => host, - fetchImpl: (_url, { signal }) => - new Promise((_, reject) => { - signals.push(signal); - signal.addEventListener('abort', () => reject(signal.reason), { - once: true, - }); - }), - }); - host.collection = h.viewer.imageryLayers; - t.after(() => h.rendering.clear()); - const first = h.rendering.prefetch(snapshot, times[1]); - const shown = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - assert.equal( - await shown, - true, - 'current frame never waits for speculative work', - ); - assert.equal(await first, false); - assert.ok(signals.every((signal) => signal.aborted)); - const second = h.rendering.prefetch(snapshot, times[1]); - host = { collection: null, kind: 'none' }; - h.rendering.rehome(); - assert.equal(await second, false); - const count = signals.length; - assert.equal(await h.rendering.prefetch(snapshot, times[2]), false); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - h.rendering.setHidden(true); - assert.equal(await h.rendering.prefetch(snapshot, times[2]), false); - assert.equal(signals.length, count); - assert.equal(h.rendering.getDiagnostics().cache.prefetching, false); -}); - -test('failed prefetch leaves the current frame and errors unchanged and releases its deadline', async (t) => { - t.mock.timers.enable({ apis: ['setTimeout'] }); - const h = renderingHarness({ - fetchImpl: async () => { - throw new Error('offline'); - }, - }); - t.after(() => h.rendering.clear()); - const shown = h.rendering.setFrame(snapshot, times[0]); - h.settle(); - await shown; - const before = h.rendering.getDiagnostics(); - assert.equal(await h.rendering.prefetch(snapshot, times[1]), false); - assert.deepEqual(h.rendering.getDiagnostics(), before); - t.mock.timers.tick(30_000); - assert.deepEqual(h.rendering.getDiagnostics(), before); -}); - test('layer warms the next advertised observation only after a successful playing frame, wrapping at the end', async (t) => { const clock = createWeatherClock(); const h = layerHarness({ clock }); @@ -2333,33 +1674,6 @@ test('layer warms the next advertised observation only after a successful playin assert.equal(warmed.length, 2, 'hidden products do not prefetch'); }); -for (const product of ['radar', 'clouds-regional', 'lightning']) { - test(`${product} on the globe host uses 256 px tiles to level 6`, async (t) => { - const collection = new ImageryLayerCollection(); - const h = renderingHarness({ - getHost: () => ({ collection, kind: 'globe' }), - }); - t.after(() => h.rendering.clear()); - h.viewer.camera = { - moveEnd: event(), - positionCartographic: { height: 60_000 }, - }; - const pending = h.rendering.setFrame({ ...snapshot, product }, times[0]); - const options = h.providers[0].options; - assert.equal(options.tileWidth, 256); - assert.equal(options.tileHeight, 256); - assert.equal(options.maximumLevel, 6); - assert.equal( - new URL(options.url, 'https://example.test').searchParams.get('size'), - '256', - ); - assert.equal(h.rendering.getDiagnostics().host, 'globe'); - h.rendering.clear(); - assert.equal(await pending, false); - assert.equal(h.viewer.camera.moveEnd.size, 0); - }); -} - test('a product replacement accepts supersession but never paints the obsolete first response', async () => { const pending = []; const h = layerHarness({ @@ -2389,14 +1703,98 @@ test('a product replacement accepts supersession but never paints the obsolete f test('disabled weather releases its camera listener and re-enabling binds only once', () => { const h = layerHarness(); h.layer.disable(); - assert.equal(h.moveEnd.size, 0, 'disabled layers own no active camera work'); + assert.equal( + h.moveEnd.numberOfListeners, + 0, + 'disabled layers own no active camera work', + ); h.layer.enable(); h.layer.enable(); - assert.equal(h.moveEnd.size, 1); + assert.equal(h.moveEnd.numberOfListeners, 1); h.layer.disable(); - assert.equal(h.moveEnd.size, 0); + assert.equal(h.moveEnd.numberOfListeners, 0); h.layer.enable(); - assert.equal(h.moveEnd.size, 1); + assert.equal(h.moveEnd.numberOfListeners, 1); h.layer.destroy(); - assert.equal(h.moveEnd.size, 0); + assert.equal(h.moveEnd.numberOfListeners, 0); }); + +for (const [id, product] of [ + ['weather-radar', 'radar'], + ['weather-satellite', 'clouds-regional'], + ['weather-satellite', 'clouds'], + ['weather-lightning', 'lightning'], +]) { + test(`${product} shows on 3D Tiles at street level and keeps its history through host changes`, async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + t.mock.method(Date, 'now', () => Date.parse(times[2])); + const size = + product === 'clouds' + ? { width: 2048, height: 1024 } + : { width: 4096, height: 2048 }; + const h = fakeImagery(); + h.setSurface('tiles'); + const events = target(); + const layer = createWeatherLayer({ + id, + feed: { getSnapshot: async () => ({ ...snapshot, product }) }, + createNativeRendering: (engine, options) => + createWeatherDisplay(engine, { + ...displayOptions, + decodeImage: async () => ({ ...size, close() {} }), + ...options, + }), + documentRef: target({ hidden: false }), + eventTarget: events, + matchMedia: () => target({ matches: false }), + }); + layer.init(null, { engine: h.engine }); + layer.setParams({ product }); + layer.enable(); + await layer.update(); + await flush(); + assert.equal(layer.getDiagnostics().time, times[2]); + assert.equal(layer.getDiagnostics().host, 'shell'); + assert.equal(layer.getRowControls().summary.status, null); + assert.doesNotMatch(layer.getRowControls().info, /Hidden/); + const [shell] = h.layers(); + assert.equal(shell.raise > 0, true); + assert.equal(shell.shown, true); + const shown = () => h.engine.fake.imageryLayer(shell.id)?.shown; + + layer.setParams({ play: true }); + t.mock.timers.tick(2000); + await flush(); + assert.equal( + layer.getDiagnostics().time, + times[0], + 'history advances at street level', + ); + h.moveEnd.raiseEvent(); + assert.equal(shown(), true, 'no height gate'); + assert.equal(layer.getDiagnostics().playing, true); + assert.equal(layer.getDiagnostics().timerActive, true); + + h.setSurface('none'); + events.emit('gev:map-stack-changed'); + assert.equal(shown(), false); + assert.equal(layer.getRowControls().summary.status, NO_IMAGERY_HOST); + assert.equal(layer.getDiagnostics().timerActive, false); + h.setSurface('globe'); + events.emit('gev:map-stack-changed'); + assert.equal(h.engine.fake.imageryLayer(shell.id), undefined); + assert.equal( + layer.getDiagnostics().time, + times[0], + 'the switch restages the shown history frame', + ); + await flush(); + assert.equal(layer.getDiagnostics().host, 'globe'); + assert.equal(layer.getDiagnostics().time, times[0]); + assert.equal(layer.getDiagnostics().loading, false); + layer.destroy(); + assert.equal(h.moveEnd.numberOfListeners, 0); + assert.equal(events.size, 0); + assert.deepEqual(h.layers(), []); + }); +} diff --git a/src/layers/wind/fieldUnavailableLocale.test.mjs b/src/layers/wind/fieldUnavailableLocale.test.mjs index 8be2fa347..25d29be4c 100644 --- a/src/layers/wind/fieldUnavailableLocale.test.mjs +++ b/src/layers/wind/fieldUnavailableLocale.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale, t } from '../../i18n/index.js'; import { SCALAR_UNAVAILABLE, WIND_FIELDS } from './fields.js'; -import { fieldUnavailableText } from './rendering.js'; +import { fieldUnavailableText } from './fields.js'; for (const locale of ['es', 'ru']) { test(`${locale}: every field's unavailable status has a translation`, () => { diff --git a/src/layers/wind/fields.js b/src/layers/wind/fields.js index a740d9478..d44ff7ba0 100644 --- a/src/layers/wind/fields.js +++ b/src/layers/wind/fields.js @@ -11,6 +11,20 @@ export const SCALAR_UNAVAILABLE = Object.freeze({ pressure: msg('Mean sea level pressure field unavailable'), }); +// English for diagnostics; the row status shows it translated (index.js). The +// scalar texts are the source's own. +const FIELD_UNAVAILABLE = Object.freeze({ + speed: msg('Wind speed field unavailable'), + ...SCALAR_UNAVAILABLE, +}); + +/** English status for a scalar overlay with no drawable field. */ +export function fieldUnavailableText(overlay) { + return Object.hasOwn(FIELD_UNAVAILABLE, overlay) + ? FIELD_UNAVAILABLE[overlay] + : null; +} + /** Fixed physical scales shared by imagery and the field legend. */ export const WIND_FIELDS = Object.freeze({ speed: Object.freeze({ diff --git a/src/layers/wind/index.js b/src/layers/wind/index.js index 3507c87e3..928573057 100644 --- a/src/layers/wind/index.js +++ b/src/layers/wind/index.js @@ -1,5 +1,4 @@ -import * as Cesium from 'cesium'; -import { createWindRendering } from './rendering.js'; +import { createWindDisplay } from './render/display.js'; import { WIND_FIELDS } from './fields.js'; import { formatWindReading, @@ -50,14 +49,15 @@ export function windStats(manifest) { }; } -/** Construct one wind layer with an explicit source and owned animation. */ +/** Construct one wind layer with an explicit source and owned animation. It + * draws through its display (render/display.js) on the engine it is attached + * to: `createNativeRendering(engine, options)`, the application's, which + * passes each engine's streamlines in, or the display made here, which draws + * the colour field alone. */ export function createWindLayer({ feed, clock, - cesium = null, - container, - createRendering = createWindRendering, - createNativeRendering, + createNativeRendering = createWindDisplay, } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Wind requires a snapshot source'); @@ -66,7 +66,6 @@ export function createWindLayer({ let request = null; let enabled = false; let rendering = null; - let imageryHost = null; let manifest = null; let error = null; let loading = false; @@ -125,44 +124,18 @@ export function createWindLayer({ attachRenderer(nextViewer, { engine: nextEngine = null } = {}) { viewer = nextViewer; engine = nextEngine; - const cesiumRendering = (options) => - createRendering({ - // Read on Cesium only: the production build's Cesium global - // loads with the first Cesium globe. - cesium: cesium ?? Cesium, - container: container ?? nextViewer.container, - getViewer: () => viewer, - getHost: () => - imageryHost?.() ?? { - collection: viewer.imageryLayers ?? viewer.scene?.imageryLayers, - kind: 'globe', - }, - onStatusChange: notify, - ...options, - }); - // The engine's display (render/display.js) when the layer has one, - // with Cesium's flow (no field) on Cesium; an engine's own flow - // backend takes its place. Else the Cesium renderer draws it all. - rendering = - (engine - ? createNativeRendering?.(engine, { - flow: nextViewer ? () => cesiumRendering({ field: false }) : null, - onStatusChange: notify, - }) - : null) ?? cesiumRendering(); - rendering.attach(); - rendering.setOptions?.({ overlay, paused }); - const target = - container ?? nextViewer?.container ?? engine?.host.container; + if (!engine) return; + rendering = createNativeRendering(engine, { onStatusChange: notify }); + rendering?.attach(); + rendering?.setOptions?.({ overlay, paused }); inspectionMarker = createWindInspectionMarker({ - container: target, + container: engine.host.container, viewer, - cesium: engine ? null : (cesium ?? Cesium), engine, }); if (enabled && manifest && !manifest.unavailable) { - rendering.setField(manifest); - rendering.start(); + rendering?.setField(manifest); + rendering?.start(); } if (sampledPosition) inspectionMarker.show(sampledPosition); }, @@ -170,13 +143,9 @@ export function createWindLayer({ inspectionMarker?.destroy(); inspectionMarker = null; rendering?.destroy(); - rendering = imageryHost = viewer = engine = null; + rendering = viewer = engine = null; }, - attachShellServices(services) { - imageryHost = - typeof services?.imageryHost === 'function' - ? services.imageryHost - : null; + attachShellServices() { rendering?.rehome?.(); }, enable() { diff --git a/src/layers/wind/index.test.mjs b/src/layers/wind/index.test.mjs index d30af79d1..30ef80423 100644 --- a/src/layers/wind/index.test.mjs +++ b/src/layers/wind/index.test.mjs @@ -4,6 +4,7 @@ import { createWindLayer, formatWindValidTime, windStats } from './index.js'; import { useWindInspectionStore } from '../../ui/stores/windInspection.js'; import { applicationPinia } from '../../ui/vueRuntime.js'; import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; const snapshot = (model) => ({ model, @@ -19,8 +20,11 @@ function harness(feed, diagnostics = {}) { ]), ); rendering.getDiagnostics = () => diagnostics; - const layer = createWindLayer({ feed, createRendering: () => rendering }); - layer.init({ container: {} }); + const layer = createWindLayer({ + feed, + createNativeRendering: () => rendering, + }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); return { layer, calls }; } @@ -261,12 +265,12 @@ test('renderer readiness pushes fresh loading stats and controls to the displaye }; const layer = createWindLayer({ feed: { getSnapshot: async () => snapshot('gfs') }, - createRendering(options) { + createNativeRendering(engine, options) { statusChanged = options.onStatusChange; return rendering; }, }); - layer.init({ container: {} }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); let displayed; layer.setRowControlsListener(() => { @@ -281,12 +285,18 @@ test('renderer readiness pushes fresh loading stats and controls to the displaye 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); + 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); + assert.equal( + displayed.info.split('\n').length, + loadingInfo.split('\n').length, + ); layer.destroy(); }); @@ -300,7 +310,11 @@ test('weather summary describes the selected forecast and count remains numeric' 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'); + assert.equal( + layer.getRowControls().chips.find((c) => c.id === 'overlay-temperature') + .label, + 'Temperature', + ); layer.destroy(); }); @@ -315,27 +329,44 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis }; const viewer = { container, - camera: { positionWC: {}, pickEllipsoid: () => { samples++; return geo.cartesian3.fromDegrees(0, 0); } }, + camera: { + positionWC: {}, + pickEllipsoid: () => { + samples++; + return geo.cartesian3.fromDegrees(0, 0); + }, + }, scene: { mode: 3, - canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 0, top: 0 }) }, + canvas: { + clientWidth: 800, + clientHeight: 600, + getBoundingClientRect: () => ({ left: 0, top: 0 }), + }, cartesianToCanvasCoordinates: () => ({ x: 400, y: 300 }), - postRender: { addEventListener(fn) { listener = fn; return () => { listener = null; }; } }, + postRender: { + addEventListener(fn) { + listener = fn; + return () => { + listener = null; + }; + }, + }, requestRender() {}, }, }; - const rendering = Object.fromEntries(['attach', 'start', 'stop', 'clear', 'destroy', 'setField'].map(name => [name, () => {}])); + 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, - + createNativeRendering: () => rendering, }); - layer.init(viewer); + // A Cesium page: the viewer samples, the engine projects the marker. + layer.init(viewer, { engine: createFakeEngine({ container }) }); layer.enable(); await layer.update(); layer.setParams({ inspect: true }); @@ -350,26 +381,52 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis 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.deepEqual( + layer.getRowControls().summary.settings.map(({ label }) => label), + ['MODEL', 'FIELD', 'UNITS', 'MOTION'], + ); assert.ok(marker()); assert.equal(typeof listener, 'function'); - viewer.camera.pickEllipsoid = () => { samples++; return geo.cartesian3.fromDegrees(70, 20); }; + viewer.camera.pickEllipsoid = () => { + samples++; + return geo.cartesian3.fromDegrees(70, 20); + }; layer.setParams({ overlay: 'speed' }); - assert.equal(layer.getRowControls().summary.reading.coordinates, captured.coordinates); + assert.equal( + layer.getRowControls().summary.reading.coordinates, + captured.coordinates, + ); layer.setParams({ model: 'ifs' }); - await new Promise(resolve => setImmediate(resolve)); + 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/); + 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); + 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( + 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()]) { + for (const change of [ + () => layer.setParams({ inspect: false }), + () => layer.disable(), + ]) { layer.setParams({ inspect: true }); assert.ok(marker()); change(); @@ -384,14 +441,24 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis 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 }); + 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.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.match( + layer.getRowControls().info, + /Forecast · does not follow history/, + ); assert.deepEqual(layer.getParams(), params); await clock.latest(); assert.equal(layer.getRowControls().summary.status, null); @@ -403,16 +470,43 @@ test('observed history labels wind as a forecast without changing its data or pa }); 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(), []); } + let renderMode = 'gpu-streamlines'; + let imageryError = null; + const layer = createWindLayer({ + feed: { getSnapshot: async () => complete('gfs') }, + createNativeRendering: () => ({ + attach() {}, + start() {}, + clear() {}, + setField() {}, + setOptions() {}, + getDiagnostics: () => ({ renderMode, imageryError }), + stop() {}, + destroy() {}, + }), + }); + layer.init(null, { engine: createFakeEngine() }); + 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(); }); @@ -430,9 +524,9 @@ test('a parameter replacement during manager enable does not roll the layer back getSnapshot: (args) => new Promise((resolve) => pending.push({ ...args, resolve })), }, - createRendering: () => rendering, + createNativeRendering: () => rendering, }); - const manager = new LayerLifecycle({ container: {} }); + const manager = new LayerLifecycle(null, { engine: createFakeEngine() }); manager.register(layer); const enabled = manager.setEnabled('wind', true); for (let i = 0; i < 20 && !pending.length; i++) await Promise.resolve(); @@ -448,3 +542,32 @@ test('a parameter replacement during manager enable does not roll the layer back assert.equal(layer.getStats().count, 4); await manager.destroyAll(); }); + +test('without a renderer of the application the layer draws its display on the engine; without an engine nothing', async () => { + const feed = { getSnapshot: async () => complete('gfs') }; + const engine = createFakeEngine(); + const layer = createWindLayer({ feed }); + layer.init(null, { engine }); + assert.equal(engine.frames.preRender.numberOfListeners, 1); + layer.enable(); + await layer.update(); + // The display's own diagnostics: the field, and no streamlines. + assert.deepEqual(layer.getDiagnostics(), { + overlay: 'none', + paused: false, + imageryActive: false, + host: null, + field: null, + imageryError: null, + }); + layer.destroy(); + assert.equal(engine.frames.preRender.numberOfListeners, 0); + + const bare = createWindLayer({ feed }); + bare.init(null); + bare.enable(); + assert.equal(await bare.update(), true); + assert.equal(bare.getStats().count, 4); + assert.deepEqual(bare.getDiagnostics(), {}); + bare.destroy(); +}); diff --git a/src/layers/wind/model.js b/src/layers/wind/model.js index 1c38e676c..575952356 100644 --- a/src/layers/wind/model.js +++ b/src/layers/wind/model.js @@ -87,3 +87,25 @@ export function advectParticle( ); return particle; } + +// 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. +export 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; +} diff --git a/src/layers/wind/render/cesiumFlow.js b/src/layers/wind/render/cesiumFlow.js new file mode 100644 index 000000000..0c743e4ca --- /dev/null +++ b/src/layers/wind/render/cesiumFlow.js @@ -0,0 +1,13 @@ +// Cesium's backend of the wind display: the wind renderer's streamlines +// (../rendering.js) over the viewer, read only once a Cesium globe draws. +import * as Cesium from 'cesium'; +import { createWindRendering } from '../rendering.js'; + +export function createWindCesiumFlow(engine, { onStatusChange } = {}) { + return createWindRendering({ + cesium: Cesium, + container: engine.host.container, + getViewer: () => engine.extensions.cesium.viewer, + onStatusChange, + }); +} diff --git a/src/layers/wind/render/display.js b/src/layers/wind/render/display.js index 3467dbc78..5f2b3f2b0 100644 --- a/src/layers/wind/render/display.js +++ b/src/layers/wind/render/display.js @@ -3,12 +3,12 @@ // imagery drapes on the globe and raised at the wind's shell height on 3D // tiles, faded out as the camera comes down while streamlines draw (they // carry the wind near the ground). The streamlines are the engine's own, -// behind one seam: the `flow` backend each engine passes in (Cesium's -// wind renderer without its field, rendering.js; Navara's flow mesh, -// render/navaraFlow.js). Without one the field draws alone. +// behind one seam: the `flow` backend the application passes in for each +// engine (Cesium's wind renderer, rendering.js, on the page's SDK; +// Navara's flow mesh, render/navaraFlow.js). Without one the field draws +// alone. -import { createFieldRaster } from '../fields.js'; -import { fieldUnavailableText } from '../rendering.js'; +import { createFieldRaster, fieldUnavailableText } from '../fields.js'; import { NO_IMAGERY_HOST } from '../../weather/imageryHost.js'; import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js'; import { WEATHER_IMAGERY_ORDER } from '../../weather/render/display.js'; @@ -48,11 +48,12 @@ function rasterCanvas(raster) { /** * The wind layer's renderer on any engine (index.js). * @param {import('../../../globe/contract.js').GlobeEngine} engine - * @param {{ flow?: (engine: object) => object, onStatusChange?: () => void, + * @param {{ flow?: (engine: object, options: object) => object, + * onStatusChange?: () => void, * createCanvas?: (raster: object) => object }} [options] - * `flow` makes the engine's streamlines (rendering.js's interface: - * attach, setField, setOptions, start, stop, clear, destroy, - * getParticleCount, getDiagnostics). + * `flow(engine, { onStatusChange })` makes the engine's streamlines + * (rendering.js's interface: attach, setField, setOptions, start, stop, + * clear, destroy, getParticleCount, getDiagnostics). */ export function createWindDisplay( engine, @@ -64,7 +65,7 @@ export function createWindDisplay( ) { if (!engine?.imagery) throw new TypeError('The wind display needs a globe engine'); - const flow = makeFlow?.(engine) ?? null; + const flow = makeFlow?.(engine, { onStatusChange }) ?? null; const id = `wind-field-${++fields}`; let snapshot = null; let overlay = 'none'; @@ -209,7 +210,7 @@ export function createWindDisplay( ready: layer.ready(), } : null, - imageryError: imageryError ?? drawn.imageryError ?? null, + imageryError, }; }, }; diff --git a/src/layers/wind/render/display.test.mjs b/src/layers/wind/render/display.test.mjs index 079c73e3b..db98d3f58 100644 --- a/src/layers/wind/render/display.test.mjs +++ b/src/layers/wind/render/display.test.mjs @@ -1,10 +1,10 @@ -// The wind display on the fake engine: its field as one imagery image, +// The wind display on the fake and headless Cesium engines: its field as one imagery image, // faded with the camera while streamlines draw, raised on 3D tiles; the // flow is the backend's. -import test from 'node:test'; import assert from 'node:assert/strict'; import * as geo from '../../../geo/index.js'; -import { createFakeEngine } from '../../../globe/testing/fakeEngine.js'; +import { imageryDisplayTests } from '../../../testSupport/imageryDisplayEngines.mjs'; +const { test, createEngine } = imageryDisplayTests(); import { WEATHER_SHELL_HEIGHTS } from '../../weather/shellRendering.js'; import { createWindDisplay, windFieldAlpha } from './display.js'; @@ -55,7 +55,7 @@ test('the field fades below 1200 km while streamlines draw', () => { }); test('the field is one image layer over the globe, faded by height; the flow draws the paths', () => { - const engine = createFakeEngine(); + const engine = createEngine(); const flow = flowStub(); const display = createWindDisplay(engine, { flow: () => flow, @@ -94,7 +94,7 @@ test('the field is one image layer over the globe, faded by height; the flow dra }); test('on 3D tiles the field is raised at the wind’s shell height; none: a status', () => { - const engine = createFakeEngine(); + const engine = createEngine(); let surface = 'tiles'; engine.imagery.surface = () => surface; let changes = 0; @@ -119,3 +119,186 @@ test('on 3D tiles the field is raised at the wind’s shell height; none: a stat assert.equal(display.getDiagnostics().host, 'globe'); assert.equal(display.getDiagnostics().imageryError, null); }); + +// A display whose field layer counts its alpha writes, over a flow stub that +// draws streamlines (`flowing`) or the canvas fallback. +function fieldHarness({ flowing = true, surface = null } = {}) { + const engine = createEngine(); + if (surface) engine.imagery.surface = () => surface.value; + const create = engine.imagery.createLayer.bind(engine.imagery); + const counts = { writes: 0, canvases: 0 }; + engine.imagery.createLayer = (options) => { + const layer = create(options); + return { + ...layer, + setAlpha(value) { + counts.writes++; + layer.setAlpha(value); + }, + }; + }; + const flow = flowStub(); + flow.getDiagnostics = () => ({ + renderMode: flowing ? 'gpu-streamlines' : 'canvas-fallback', + }); + const display = createWindDisplay(engine, { + flow: () => flow, + createCanvas: (raster) => { + counts.canvases++; + return canvasOf(raster); + }, + }); + const field = () => { + const [id] = engine.fake.imageryLayers(); + return id ? engine.fake.imageryLayer(id) : undefined; + }; + return { engine, display, flow, counts, field }; +} +const withScalar = (kind, value) => ({ + ...snapshot(), + scalar: { + kind, + units: kind === 'pressure' ? 'hPa' : '°C', + values: new Float32Array(40).fill(value), + }, +}); +const MIDPOINT = Math.sqrt(200_000 * 1_200_000); + +test('the field builds once, survives frames and pause, replaces cleanly and releases on clear', () => { + const { engine, display, counts, field } = fieldHarness(); + display.attach(); + display.setOptions({ overlay: 'speed' }); + display.setField(snapshot()); + display.start(); + assert.equal(engine.fake.imageryLayers().length, 1); + assert.equal(counts.canvases, 1); + assert.equal(field().source.image.width, 360); + assert.equal(field().source.image.height, 181); + engine.fake.frames(3); + assert.equal(counts.canvases, 1, 'no texture generation from frames'); + display.setOptions({ paused: true }); + assert.equal(engine.fake.imageryLayers().length, 1); + display.setOptions({ overlay: 'temperature' }); + assert.deepEqual( + engine.fake.imageryLayers(), + [], + 'unavailable scalar cannot display the previous field', + ); + assert.match(display.getDiagnostics().imageryError, /unavailable/); + display.setField(withScalar('temperature', 20)); + assert.equal(engine.fake.imageryLayers().length, 1); + assert.equal(display.getDiagnostics().imageryError, null); + display.clear(); + assert.deepEqual(engine.fake.imageryLayers(), []); + display.destroy(); +}); + +for (const overlay of ['speed', 'pressure', 'temperature']) { + test(`${overlay} field fades with log camera height, skips small changes and follows height while paused`, () => { + const { engine, display, counts, field } = fieldHarness(); + const base = overlay === 'temperature' ? 1 : 0.85; + display.attach(); + display.setOptions({ overlay }); + display.setField( + overlay === 'speed' + ? snapshot() + : withScalar(overlay, overlay === 'pressure' ? 1013 : 20), + ); + display.start(); + at(engine, 17_368_000); + assert.equal(field().alpha, base); + assert.equal(field().shown, true); + const writes = counts.writes; + at(engine, 17_368_000); + assert.equal(counts.writes, writes, 'unchanged height never writes alpha'); + at(engine, MIDPOINT); + assert.ok(Math.abs(field().alpha - base / 2) < 1e-6, 'log midpoint'); + const midpoint = counts.writes; + at(engine, MIDPOINT * 1.001); + assert.equal(counts.writes, midpoint, 'sub-0.005 changes are skipped'); + at(engine, MIDPOINT * 1.03); + assert.equal(counts.writes, midpoint + 1, 'larger changes write alpha'); + at(engine, 100_000); + assert.equal(field().alpha, 0); + assert.equal(field().shown, false); + display.setOptions({ paused: true }); + at(engine, 1_200_000); + assert.equal(field().alpha, base, 'a paused field still follows height'); + assert.equal(field().shown, true); + assert.equal(counts.canvases, 1, 'height changes never rebuild it'); + display.destroy(); + }); +} + +test('without streamlines the field keeps its base alpha low down', () => { + const { engine, display, field } = fieldHarness({ flowing: false }); + display.attach(); + display.setOptions({ overlay: 'speed' }); + display.setField(snapshot()); + at(engine, 1_200); + assert.equal(field().alpha, 0.85); + assert.equal(field().shown, true); + display.destroy(); +}); + +for (const overlay of ['speed', 'temperature']) { + test(`${overlay} raised field fades in 0.1 steps; without streamlines it shows at street level`, () => { + const surface = { value: 'tiles' }; + const base = overlay === 'temperature' ? 1 : 0.85; + const field = overlay === 'speed' ? snapshot() : withScalar(overlay, 20); + const h = fieldHarness({ surface }); + h.display.attach(); + h.display.setOptions({ overlay }); + h.display.setField(field); + at(h.engine, MIDPOINT); + assert.equal(h.field().raise, WEATHER_SHELL_HEIGHTS.wind); + assert.equal(h.field().alpha, overlay === 'temperature' ? 0.5 : 0.4); + const writes = h.counts.writes; + at(h.engine, MIDPOINT * 1.01); + assert.equal(h.counts.writes, writes, 'the same step writes nothing'); + at(h.engine, 100_000); + assert.equal(h.field().alpha, 0); + assert.equal(h.field().shown, false); + at(h.engine, 1_200_000); + assert.equal(h.field().alpha, overlay === 'temperature' ? 1 : 0.9); + // Back on the globe: the smooth fade. + surface.value = 'globe'; + at(h.engine, 1_200_000); + assert.equal(h.field().raise, null); + assert.equal(h.field().alpha, base); + h.display.destroy(); + + const fallback = fieldHarness({ + flowing: false, + surface: { value: 'tiles' }, + }); + fallback.display.attach(); + fallback.display.setOptions({ overlay }); + fallback.display.setField(field); + at(fallback.engine, 1_200); + assert.equal(fallback.field().shown, true, 'no height gate on 3D tiles'); + assert.equal(fallback.field().alpha, Math.round(base * 10) / 10); + at(fallback.engine, 1_500); + assert.equal(fallback.counts.canvases, 1, 'camera changes keep the field'); + fallback.display.destroy(); + }); +} + +test('a field installed with no imagery surface reports hidden and installs on restore', () => { + const surface = { value: 'none' }; + const { engine, display, counts } = fieldHarness({ surface }); + display.attach(); + display.setOptions({ overlay: 'speed' }); + display.setField(snapshot()); + assert.deepEqual(engine.fake.imageryLayers(), []); + assert.equal(counts.canvases, 0); + assert.equal( + display.getDiagnostics().imageryError, + 'Hidden by this map source · choose a globe map', + ); + surface.value = 'globe'; + display.rehome(); + assert.equal(engine.fake.imageryLayers().length, 1); + assert.equal(display.getDiagnostics().imageryError, null); + display.destroy(); +}); diff --git a/src/layers/wind/render/navaraFlow.js b/src/layers/wind/render/navaraFlow.js index 10f61bd1c..ce0065ecc 100644 --- a/src/layers/wind/render/navaraFlow.js +++ b/src/layers/wind/render/navaraFlow.js @@ -3,7 +3,7 @@ import { WIND_NARROW_PATH_LIMIT, WIND_PATH_LIMIT, } from '../streamlines.js'; -import { sameWind } from '../rendering.js'; +import { sameWind } from '../model.js'; import { createWindFlowMesh } from './flowMesh.js'; const clamp = (n) => Math.max(0, Math.min(1, n)); diff --git a/src/layers/wind/rendering.js b/src/layers/wind/rendering.js index b29398ba8..3314e1b9d 100644 --- a/src/layers/wind/rendering.js +++ b/src/layers/wind/rendering.js @@ -1,78 +1,19 @@ -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 { msg } from '../../i18n/index.js'; import { createWindGpuRendering } from './gpuRendering.js'; -import { advectParticle, sampleWind } from './model.js'; -import { - createFieldRaster, - SCALAR_UNAVAILABLE, - trailEraseAlpha, - windTrailColor, -} from './fields.js'; +import { advectParticle, sampleWind, sameWind } from './model.js'; +import { trailEraseAlpha, windTrailColor } from './fields.js'; -// English for diagnostics; the row status shows it translated (index.js). The -// scalar texts are the source's own; setOptions accepts only these overlays. -const FIELD_UNAVAILABLE = Object.freeze({ - speed: msg('Wind speed field unavailable'), - ...SCALAR_UNAVAILABLE, -}); - -/** English status for a scalar overlay with no drawable field. */ -export function fieldUnavailableText(overlay) { - return Object.hasOwn(FIELD_UNAVAILABLE, overlay) - ? FIELD_UNAVAILABLE[overlay] - : null; -} -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. -export 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. With - * `field: false` it draws the flow alone: the wind display draws the field - * through the globe contract (render/display.js). */ +/** The wind's streamlines on Cesium (the wind display's flow backend, + * render/display.js, which draws the colour field): globe-projected surface + * flow over the viewer's scene, GPU streamlines where the context has them, + * else canvas particles in `container`. The Cesium SDK is the page's, + * passed in. */ export function createWindRendering({ cesium, container, getViewer, - getHost = () => ({ - collection: - getViewer?.()?.imageryLayers ?? getViewer?.()?.scene?.imageryLayers, - kind: 'globe', - }), - eventTarget = globalThis.window, createGpuRendering = createWindGpuRendering, onStatusChange, - field: drawsField = true, } = {}) { let canvas = null; let context = null; @@ -88,12 +29,6 @@ export function createWindRendering({ 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; @@ -297,155 +232,10 @@ export function createWindRendering({ 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 (!drawsField || !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 = fieldUnavailableText(overlay); - return; - } - if ( - kind === 'tileset' - ? !cesium.Primitive - : !collection || !cesium.SingleTileImageryProvider - ) { - imageryError = msg('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 = msg('Globe field image unavailable'); - }); - viewer.scene?.requestRender?.(); - } catch { - removeImagery(); - imageryError = msg('Globe field image unavailable'); - } - } - - function rehome() { - if (!drawsField || !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) { @@ -492,7 +282,6 @@ export function createWindRendering({ 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); @@ -509,11 +298,6 @@ export function createWindRendering({ 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. @@ -536,11 +320,10 @@ export function createWindRendering({ 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); + listenScene(camera?.moveEnd, cameraMoved); } function detachListeners() { for (const remove of removers) remove(); @@ -688,7 +471,6 @@ export function createWindRendering({ } return { - rehome, attach() { if (canvas) return; canvas = document.createElement('canvas'); @@ -732,7 +514,6 @@ export function createWindRendering({ resize(viewer); seed(viewer.scene, makeOccluder(viewer.scene)); } - installImagery(); relief.attach(); motionChanged(); } @@ -749,17 +530,13 @@ export function createWindRendering({ const changedPause = newPaused !== paused; overlay = nextOverlay; paused = newPaused; - if (changedOverlay) { - installImagery(); - clearPixels(); - } + if (changedOverlay) clearPixels(); if (changedOverlay || changedPause) motionChanged(); }, start() { if (running || !canvas) return; running = true; attachListeners(); - rehome(); motionChanged(); }, stop() { @@ -781,8 +558,6 @@ export function createWindRendering({ gpuVisible = false; reportedGpuReady = null; flowTime = 0; - removeImagery(); - imageryError = null; clearPixels(); }, destroy() { @@ -809,10 +584,6 @@ export function createWindRendering({ relief: relief.getDiagnostics(), painted, framePending: frame !== null, - imageryActive: imagery !== null || shell !== null, - host: shell ? 'shell' : imagery ? 'globe' : null, - shell: shell?.getDiagnostics() ?? null, - imageryError, }; }, }; diff --git a/src/layers/wind/rendering.test.mjs b/src/layers/wind/rendering.test.mjs index d05d42351..9ed0334e5 100644 --- a/src/layers/wind/rendering.test.mjs +++ b/src/layers/wind/rendering.test.mjs @@ -1,13 +1,10 @@ +// The wind's streamlines on Cesium (rendering.js, the wind display's flow +// backend) over a stub viewer and SDK. The colour field is the display's +// (render/display.test.mjs). import test from 'node:test'; import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; import { createWindRendering } from './rendering.js'; -import { NO_IMAGERY_HOST } from '../weather/imageryHost.js'; -import { - createShellCesium, - createShellScene, -} from '../weather/shellFixture.mjs'; function event() { const listeners = new Set(); @@ -32,14 +29,9 @@ function harness({ projected = null, createGpuRendering, onStatusChange, - getHost, - eventTarget, - shell = false, } = {}) { - const shellScene = shell ? createShellScene() : null; const strokes = []; const clears = []; - const textures = []; const erasures = []; let path = []; const context = { @@ -76,13 +68,11 @@ function harness({ clientWidth: 800, clientHeight: 600, getContext: () => context, - toDataURL: () => 'data:image/png;base64,test', remove() { this.removed = true; }, }); const canvas = makeCanvas(); - let created = 0; const container = { clientWidth: 800, clientHeight: 600, @@ -115,12 +105,7 @@ function harness({ }; globalThis.document = { hidden: false, - createElement() { - if (!created++) return canvas; - const item = makeCanvas(); - textures.push(item); - return item; - }, + createElement: () => canvas, addEventListener(type, cb) { this.removeVisibility = visibility.addEventListener(cb); }, @@ -138,35 +123,8 @@ function harness({ globalThis.cancelled = true; pending.delete(id); }; - const imagery = []; - const removed = []; - const imageryLayers = { - addImageryProvider(provider) { - let alpha = 1; - const layer = { - provider, - show: true, - alphaWrites: 0, - get alpha() { - return alpha; - }, - set alpha(value) { - alpha = value; - this.alphaWrites++; - }, - }; - imagery.push(layer); - return layer; - }, - remove(layer, destroy) { - const i = imagery.indexOf(layer); - if (i >= 0) imagery.splice(i, 1); - removed.push({ layer, destroy }); - }, - }; const viewer = { container, - imageryLayers, scene: { canvas, camera: { @@ -178,23 +136,12 @@ function harness({ }, preRender, requestRender() {}, - ...(shellScene - ? { - primitives: shellScene.primitives, - postRender: shellScene.postRender, - } - : {}), }, isDestroyed: () => false, }; const cesium = { - ...(shell ? createShellCesium() : {}), - GeographicTilingScheme: Cesium.GeographicTilingScheme, - Credit: Cesium.Credit, - Event: Cesium.Event, Cartesian3: { fromDegrees: (lon, lat) => ({ lon, lat }) }, Ellipsoid: { WGS84: {} }, - Rectangle: { MAX_VALUE: {} }, EllipsoidalOccluder: class { isPointVisible() { return !occluded; @@ -203,12 +150,6 @@ function harness({ SceneTransforms: { worldToWindowCoordinates: projected ?? (() => ({ x: 100, y: 100 })), }, - SingleTileImageryProvider: class { - constructor(options) { - this.options = options; - this.errorEvent = event(); - } - }, }; const rendering = createWindRendering({ cesium, @@ -216,8 +157,6 @@ function harness({ getViewer: () => viewer, createGpuRendering, onStatusChange, - getHost, - eventTarget, }); return { rendering, @@ -227,7 +166,6 @@ function harness({ pending, strokes, clears, - textures, erasures, visibility, media, @@ -235,9 +173,6 @@ function harness({ preRender, viewer, cesium, - imagery, - removed, - primitives: () => shellScene?.primitives.items ?? [], }; } @@ -389,169 +324,6 @@ test('reduced motion and hidden documents never retain an animation callback', ( h.rendering.destroy(); }); -test('scalar imagery builds once, survives pause, replaces cleanly, and releases on clear', () => { - const h = harness(); - h.rendering.attach(); - h.rendering.setOptions({ overlay: 'speed' }); - h.rendering.setField({ grid: FIELD, u: FIELD.u, v: FIELD.v }); - h.rendering.start(); - assert.equal(h.imagery.length, 1); - assert.equal(h.textures.length, 1); - assert.equal(h.imagery[0].provider.options.tileWidth, 360); - h.callbacks.shift()(16); - h.callbacks.shift()(50); - h.callbacks.shift()(85); - assert.equal(h.textures.length, 1, 'no texture generation from frames'); - h.rendering.setOptions({ paused: true }); - assert.equal(h.imagery.length, 1); - h.rendering.setOptions({ overlay: 'temperature' }); - assert.equal( - h.imagery.length, - 0, - 'unavailable scalar cannot display the previous field', - ); - assert.match(h.rendering.getDiagnostics().imageryError, /unavailable/); - h.rendering.setField({ - grid: FIELD, - u: FIELD.u, - v: FIELD.v, - scalar: { - kind: 'temperature', - units: '°C', - values: new Float32Array([20]), - }, - }); - assert.equal(h.imagery.length, 1); - assert.equal(h.pending.size, 0); - h.rendering.clear(); - assert.equal(h.imagery.length, 0); - assert.ok(h.removed.every((item) => item.destroy)); - h.rendering.destroy(); -}); - -for (const overlay of ['speed', 'pressure', 'temperature']) { - test(`${overlay} imagery fades with log camera height and resumes from idle`, () => { - const gpu = { - supported: () => true, - setField: () => true, - updateVisibility: (camera) => camera.positionCartographic.height > 15000, - tick() {}, - setOptions() {}, - clear() {}, - destroy() {}, - getParticleCount: () => 1, - getDiagnostics: () => ({ ready: true }), - }; - const h = harness({ createGpuRendering: () => gpu }); - const camera = h.viewer.scene.camera; - const baseAlpha = overlay === 'temperature' ? 1 : 0.85; - const snapshot = { - grid: FIELD, - u: FIELD.u, - v: FIELD.v, - scalar: { - kind: overlay, - units: overlay === 'pressure' ? 'hPa' : '°C', - values: Float32Array.from([overlay === 'pressure' ? 1013 : 20]), - }, - }; - camera.positionCartographic.height = 17_368_000; - h.rendering.attach(); - h.rendering.setOptions({ overlay }); - h.rendering.setField(snapshot); - h.rendering.start(); - let layer = h.imagery[0]; - assert.equal(layer.alpha, baseAlpha); - assert.equal(layer.show, true); - const writes = layer.alphaWrites; - h.preRender.emit(); - camera.changed.emit(); - camera.moveEnd.emit(); - assert.equal( - layer.alphaWrites, - writes, - 'unchanged height never writes alpha', - ); - - camera.positionCartographic.height = Math.sqrt(200_000 * 1_200_000); - h.preRender.emit(); - assert.ok(layer.alpha > 0 && layer.alpha < baseAlpha); - assert.ok(Math.abs(layer.alpha - baseAlpha / 2) < 1e-12, 'log midpoint'); - const midpointWrites = layer.alphaWrites; - h.preRender.emit(); - camera.positionCartographic.height *= 1.001; - h.preRender.emit(); - assert.equal( - layer.alphaWrites, - midpointWrites, - 'sub-0.005 changes are skipped', - ); - camera.positionCartographic.height *= 1.02; - h.preRender.emit(); - assert.equal( - layer.alphaWrites, - midpointWrites + 1, - 'larger changes write alpha', - ); - - camera.positionCartographic.height = 100_000; - h.preRender.emit(); - assert.equal(layer.alpha, 0); - assert.equal(layer.show, false); - h.rendering.setField({ ...snapshot }); - assert.notEqual(h.imagery[0], layer, 'reinstall replaces the layer'); - layer = h.imagery[0]; - assert.equal(layer.alpha, 0, 'reinstall at low height starts transparent'); - assert.equal(layer.show, false, 'reinstall at low height starts hidden'); - - for (const eventName of ['preRender', 'moveEnd']) { - camera.positionCartographic.height = 1200; - h.preRender.emit(); - assert.equal(h.pending.size, 0, 'street-level flow is parked'); - camera.positionCartographic.height = 1_200_000; - (eventName === 'preRender' ? h.preRender : camera.moveEnd).emit(); - assert.equal(layer.alpha, baseAlpha, 'camera event restores full alpha'); - assert.equal( - layer.show, - true, - 'camera event unhides imagery without preRender', - ); - camera.positionCartographic.height = 200_000; - (eventName === 'preRender' ? h.preRender : camera.moveEnd).emit(); - assert.equal(layer.alpha, 0, 'lower bound is transparent'); - assert.equal(layer.show, false); - } - h.rendering.setOptions({ paused: true }); - camera.positionCartographic.height = 1_200_000; - h.preRender.emit(); - assert.equal( - layer.alpha, - baseAlpha, - 'paused imagery still follows camera height', - ); - assert.equal(h.pending.size, 0); - assert.equal( - h.textures.length, - 2, - 'height changes never rebuild the texture', - ); - h.rendering.destroy(); - }); -} - -test('canvas fallback retains scalar base alpha at low camera height', () => { - const h = harness(); - h.viewer.scene.camera.positionCartographic.height = 1200; - h.rendering.attach(); - h.rendering.setOptions({ overlay: 'speed' }); - h.rendering.setField({ grid: FIELD, u: FIELD.u, v: FIELD.v }); - h.rendering.start(); - h.preRender.emit(); - assert.equal(h.imagery[0].alpha, 0.85); - assert.equal(h.imagery[0].show, true); - h.rendering.destroy(); -}); - test('projection discontinuities cannot produce long strokes across the viewport', () => { let calls = 0; const h = harness({ @@ -829,7 +601,6 @@ test('scalar snapshots retain identical GPU wind geometry and animation phase', 1, 'freshly decoded equal arrays reuse the worker-built primitive', ); - assert.equal(h.imagery.length, 1, 'new scalar field is still installed'); h.callbacks.shift()(1032); assert.ok( times.at(-1) >= phase, @@ -845,11 +616,6 @@ test('scalar snapshots retain identical GPU wind geometry and animation phase', }), ); assert.equal(builds, 1, 'revised scalar alone does not rebuild flow'); - assert.equal( - h.imagery.length, - 1, - 'scalar replacement releases the prior image', - ); h.rendering.clear(); h.rendering.setField(snapshot()); assert.equal(builds, 2, 'clear invalidates reuse'); @@ -989,207 +755,3 @@ for (const eventName of ['preRender', 'moveEnd']) { assert.equal(camera.percentageChanged, 0.5); }); } - -test('scalar field becomes a raised shell on 3D Tiles and survives no-host events', () => { - const eventTarget = new EventTarget(); - let host; - const h = harness({ - eventTarget, - shell: true, - getHost: () => - host ?? { collection: h.viewer.imageryLayers, kind: 'globe' }, - }); - h.rendering.attach(); - h.rendering.setOptions({ overlay: 'speed' }); - h.rendering.setField(FIELD); - h.rendering.start(); - const original = h.imagery[0]; - assert.ok(original); - assert.equal(h.rendering.getDiagnostics().host, 'globe'); - const tiles = { - addImageryProvider() { - throw new Error('nothing drapes on 3D Tiles'); - }, - }; - host = { collection: tiles, kind: 'tileset' }; - eventTarget.dispatchEvent(new Event('gev:map-stack-changed')); - assert.equal(h.imagery.length, 0); - assert.equal(h.removed[0].destroy, true); - const [primitive] = h.primitives(); - const geometry = primitive.options.geometryInstances.geometry.options; - assert.equal(geometry.height, 5_000); - assert.equal(geometry.rectangle, h.cesium.Rectangle.MAX_VALUE); - const material = primitive.appearance.material; - assert.equal(h.textures.length, 2); - assert.equal(material.uniforms.image, h.textures[1]); - assert.equal(h.textures[1].width, 360); - assert.equal(h.textures[1].height, 181); - assert.equal(material.uniforms.alpha, 0.85); - assert.equal(primitive.show, true); - const diagnostics = h.rendering.getDiagnostics(); - assert.equal(diagnostics.host, 'shell'); - assert.equal(diagnostics.imageryActive, true); - assert.equal(diagnostics.shell.height, 5_000); - host = { collection: null, kind: 'none' }; - eventTarget.dispatchEvent(new Event('gev:map-stack-changed')); - assert.equal(primitive.show, false); - assert.equal(h.rendering.getDiagnostics().imageryError, NO_IMAGERY_HOST); - h.viewer.scene.camera.moveEnd.emit(); - assert.equal(primitive.show, false, 'camera events keep it hidden'); - host = { collection: tiles, kind: 'tileset' }; - h.rendering.rehome(); - assert.deepEqual(h.primitives(), [primitive]); - assert.equal(primitive.show, true); - assert.equal(h.textures.length, 2, 'the retained shell is reused'); - assert.equal(h.rendering.getDiagnostics().imageryError, null); - host = null; - h.rendering.rehome(); - assert.equal(primitive.destroyed, true); - assert.equal(material.destroyed, true); - assert.equal(h.primitives().length, 0); - assert.equal(h.imagery.length, 1); - assert.equal(h.rendering.getDiagnostics().host, 'globe'); - h.rendering.destroy(); - assert.equal(h.imagery.length, 0); - assert.equal(h.viewer.scene.postRender.size, 0); - eventTarget.dispatchEvent(new Event('gev:map-stack-changed')); - assert.equal(h.primitives().length, 0); -}); - -test('scalar installation with no host reports hidden and installs on restore', () => { - let host = { collection: null, kind: 'none' }; - const h = harness({ getHost: () => host }); - h.rendering.attach(); - h.rendering.setOptions({ overlay: 'speed' }); - h.rendering.setField(FIELD); - assert.equal(h.imagery.length, 0); - assert.equal(h.textures.length, 0); - assert.equal( - h.rendering.getDiagnostics().imageryError, - 'Hidden by this map source · choose a globe map', - ); - host = { collection: h.viewer.imageryLayers, kind: 'globe' }; - h.rendering.rehome(); - assert.equal(h.imagery.length, 1); - assert.equal(h.rendering.getDiagnostics().imageryError, null); - h.rendering.destroy(); -}); - -for (const overlay of ['speed', 'temperature', 'pressure']) { - test(`${overlay} shell alpha follows the height fade in 0.1 steps on moveEnd, install or rehome`, () => { - const gpu = { - supported: () => true, - setField: () => true, - updateVisibility: () => true, - tick() {}, - setOptions() {}, - clear() {}, - destroy() {}, - getParticleCount: () => 1, - getDiagnostics: () => ({ ready: true }), - }; - let kind = 'tileset'; - const h = harness({ - shell: true, - createGpuRendering: () => gpu, - getHost: () => ({ collection: h.viewer.imageryLayers, kind }), - }); - const camera = h.viewer.scene.camera; - const field = { - ...FIELD, - scalar: { - kind: overlay, - units: overlay === 'pressure' ? 'hPa' : '°C', - values: Float32Array.of(overlay === 'pressure' ? 1013 : 20), - }, - }; - camera.positionCartographic.height = Math.sqrt(200_000 * 1_200_000); - h.rendering.attach(); - h.rendering.setOptions({ overlay }); - h.rendering.setField(field); - h.rendering.start(); - assert.equal(h.imagery.length, 0); - const [primitive] = h.primitives(); - const { uniforms } = primitive.appearance.material; - assert.equal(uniforms.alpha, overlay === 'temperature' ? 0.5 : 0.4); - let alpha = uniforms.alpha; - let writes = 0; - Object.defineProperty(uniforms, 'alpha', { - get: () => alpha, - set(value) { - alpha = value; - writes++; - }, - }); - camera.positionCartographic.height = 100_000; - h.preRender.emit(); - h.rendering.setOptions({ paused: true }); - assert.equal(writes, 0, 'preRender and pause do not change the shell'); - camera.moveEnd.emit(); - assert.equal(uniforms.alpha, 0); - assert.equal(primitive.show, false); - camera.positionCartographic.height = 59_999; - camera.moveEnd.emit(); - assert.equal(uniforms.alpha, 0, 'the fade still hides the field low down'); - assert.equal(primitive.show, false); - camera.positionCartographic.height = Math.sqrt(200_000 * 1_200_000); - camera.moveEnd.emit(); - assert.equal(primitive.show, true); - writes = 0; - camera.positionCartographic.height *= 1.01; - camera.moveEnd.emit(); - assert.equal(writes, 0, 'the same quantization bucket writes nothing'); - camera.positionCartographic.height = 1_200_000; - h.rendering.rehome(); - assert.equal(uniforms.alpha, overlay === 'temperature' ? 1 : 0.9); - kind = 'globe'; - h.rendering.rehome(); - assert.equal(primitive.destroyed, true); - assert.equal(h.imagery[0].provider.options.tileWidth, 360); - assert.equal(h.imagery[0].alpha, overlay === 'temperature' ? 1 : 0.85); - h.rendering.destroy(); - assert.equal(camera.moveEnd.size, 0); - }); -} - -for (const overlay of ['speed', 'temperature', 'pressure']) { - test(`${overlay} canvas fallback shows the shell at street level without changing globe alpha`, () => { - let kind = 'tileset'; - const h = harness({ - shell: true, - getHost: () => ({ collection: h.viewer.imageryLayers, kind }), - }); - const camera = h.viewer.scene.camera; - camera.positionCartographic.height = 1200; - h.rendering.attach(); - h.rendering.setOptions({ overlay }); - h.rendering.setField({ - ...FIELD, - scalar: { - kind: overlay, - units: overlay === 'pressure' ? 'hPa' : '°C', - values: Float32Array.of(overlay === 'pressure' ? 1013 : 20), - }, - }); - h.rendering.start(); - const [primitive] = h.primitives(); - const textures = h.textures.length; - const baseAlpha = overlay === 'temperature' ? 1 : 0.85; - assert.equal(primitive.show, true, 'no height gate on 3D Tiles'); - assert.equal(primitive.appearance.material.uniforms.alpha, baseAlpha); - camera.moveEnd.emit(); - h.preRender.emit(); - assert.equal(primitive.show, true); - assert.equal( - h.textures.length, - textures, - 'camera changes keep the field texture', - ); - kind = 'globe'; - h.rendering.rehome(); - assert.equal(primitive.destroyed, true); - assert.equal(h.imagery[0].show, true); - assert.equal(h.imagery[0].alpha, baseAlpha); - h.rendering.destroy(); - }); -} diff --git a/src/layers/wind/staleInfo.test.mjs b/src/layers/wind/staleInfo.test.mjs index fab2b95a0..42f64bf21 100644 --- a/src/layers/wind/staleInfo.test.mjs +++ b/src/layers/wind/staleInfo.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { setLocale } from '../../i18n/index.js'; import { createWindLayer } from './index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; test('stale wind forecast is marked in the UI language', async () => { const rendering = Object.fromEntries( @@ -24,9 +25,9 @@ test('stale wind forecast is marked in the UI language', async () => { reason: 'Wind upstream unavailable', }), }, - createRendering: () => rendering, + createNativeRendering: () => rendering, }); - layer.init({ container: {} }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); await layer.update(); try { diff --git a/src/layers/wind/windProblemLocale.test.mjs b/src/layers/wind/windProblemLocale.test.mjs index 0e820b119..2e7222136 100644 --- a/src/layers/wind/windProblemLocale.test.mjs +++ b/src/layers/wind/windProblemLocale.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWindLayer } from './index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { setLocale } from '../../i18n/index.js'; function harness(problem) { @@ -24,9 +25,9 @@ function harness(problem) { throw new Error(problem); }, }, - createRendering: () => rendering, + createNativeRendering: () => rendering, }); - layer.init({ container: {} }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); return layer; } diff --git a/src/layers/wind/windUpstreamReason.test.mjs b/src/layers/wind/windUpstreamReason.test.mjs index 53b70c52c..d4a3d016d 100644 --- a/src/layers/wind/windUpstreamReason.test.mjs +++ b/src/layers/wind/windUpstreamReason.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createWindLayer, windStats } from './index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { setLocale, translateKnown } from '../../i18n/index.js'; import { windProxy } from '../../../server/providers/wind.js'; @@ -17,8 +18,11 @@ function harness(feed) { ].map((name) => [name, () => {}]), ); rendering.getDiagnostics = () => ({}); - const layer = createWindLayer({ feed, createRendering: () => rendering }); - layer.init({ container: {} }); + const layer = createWindLayer({ + feed, + createNativeRendering: () => rendering, + }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); return layer; } diff --git a/src/testSupport/imageryDisplayEngines.mjs b/src/testSupport/imageryDisplayEngines.mjs new file mode 100644 index 000000000..16623e748 --- /dev/null +++ b/src/testSupport/imageryDisplayEngines.mjs @@ -0,0 +1,150 @@ +// Display cases run on both engines. Cesium uses real imagery layers and a +// headless camera; only GPU shell drawing and provider requests are faked. +import nodeTest from 'node:test'; +import * as Cesium from 'cesium'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { normalizeImageryLayerOptions } from '../globe/imagery.js'; +import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs'; + +function createCesiumImageryEngine() { + let renderRequests = 0; + let pending = false; + let frame; + const { + engine, + viewer, + frame: draw, + } = createCesiumMoversEngine({ + engineOptions: { + requestRender() { + renderRequests++; + if (pending) return; + pending = true; + queueMicrotask(() => { + pending = false; + if (!engine.isDestroyed()) frame(); + }); + }, + imageryFactories: { + tiles(options) { + const tilingScheme = + options.tilingScheme ?? new Cesium.WebMercatorTilingScheme(); + return { + tilingScheme, + rectangle: options.rectangle ?? tilingScheme.rectangle, + tileWidth: 256, + tileHeight: 256, + minimumLevel: 0, + maximumLevel: 18, + errorEvent: new Cesium.Event(), + requestImage: () => undefined, + }; + }, + shell(options) { + let detail = null; + return { + rectangle: options.rectangle, + setImage() {}, + setAlpha() {}, + setShow() {}, + setDetail: (image) => { + detail = image; + }, + detail: () => detail, + drawn: () => true, + destroy() {}, + }; + }, + }, + }, + }); + frame = draw; + viewer.scene.globe.show = true; + viewer.scene.globe.tilesLoaded = true; + viewer.imageryLayers = new Cesium.ImageryLayerCollection(); + const layers = new Map(); + const create = engine.imagery.createLayer; + engine.imagery.createLayer = (options) => { + const layer = create(options); + layers.set(layer.id, { + layer, + options: normalizeImageryLayerOptions(options), + }); + return layer; + }; + engine.fake = { + get renderRequests() { + return renderRequests; + }, + script(method, value) { + engine.camera[method] = typeof value === 'function' ? value : () => value; + }, + emit(name) { + if (name === 'frames.preRender') frame(); + else throw new Error(`Unsupported event ${name}`); + }, + frames(count) { + for (let i = 0; i < count; i++) frame(); + }, + imageryLayers: () => engine.imagery.list().map(({ id }) => id), + imageryLayer(id) { + const record = layers.get(id); + if (!record || record.layer.isDestroyed()) return undefined; + const { layer, options } = record; + return { + options, + source: layer.source(), + alpha: layer.alpha(), + shown: layer.shown(), + split: layer.split(), + raise: layer.raise(), + detail: layer.detail(), + staging: layer.staging(), + }; + }, + }; + return engine; +} + +/** Each test owns its engines and releases them even after an assertion fails. */ +export function imageryDisplayTests() { + let make; + let engines; + return { + test(name, run) { + for (const factory of [createFakeEngine, createCesiumImageryEngine]) + nodeTest( + `${name} (${factory === createFakeEngine ? 'fake' : 'cesium'})`, + async (t) => { + make = factory; + engines = []; + if (!globalThis.document) { + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, h) => ({ + data: new Uint8ClampedArray(w * h * 4), + }), + putImageData() {}, + }), + }), + }; + t.after(() => { + delete globalThis.document; + }); + } + try { + await run(t); + } finally { + for (const engine of engines) engine.destroy(); + } + }, + ); + }, + createEngine(options) { + const engine = make(options); + engines.push(engine); + return engine; + }, + }; +}