From c079b329d08cc5d4184b71992b26633b6ff87489 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 12:16:27 +0400 Subject: [PATCH] Remove the weather and wind imagery fallbacks Require engine.imagery for weather and wind and load the wind flow backend from the layer manifest. Preserve Cesium's staging, frame retention, shell ordering, wind fade and streamlines; Navara follows the same display policy. Remove obsolete rendering modules and their three translated status keys. Run every weather and wind display case on both the fake engine and the headless Cesium engine, keeping the existing behavioural assertions. Coupling: Cesium import files fall from 27 to 25, viewer lines from 1232 to 1177 and engine-name lines from 63 to 62. The explicit Cesium wind flow backend deliberately raises renderer lines from 330 to 344 and extension reads from 8 to 9; every other count stays unchanged. Browser checks to run GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291 node scripts/qa-contract-suite.mjs http://localhost:4291 node scripts/qa-weather-compare.mjs shoot http://localhost:4291 --out=qa-shots/weather-after Compare weather and wind against the previous capture on both engines. --- docs/CURRENT-STATE.md | 6 +- docs/GLOBE-PRIMITIVES.md | 6 +- scripts/engine-coupling-baseline.json | 12 +- scripts/format-scope.json | 8 +- scripts/package-boundaries.json | 12 +- src/app/layers/renderers.js | 14 +- src/i18n/locales/ar.json | 3 - src/i18n/locales/de.json | 3 - src/i18n/locales/es.json | 3 - src/i18n/locales/fr.json | 3 - src/i18n/locales/it.json | 3 - src/i18n/locales/ja.json | 3 - src/i18n/locales/pt.json | 3 - src/i18n/locales/ru.json | 3 - src/i18n/locales/uk.json | 3 - src/i18n/locales/zh-CN.json | 3 - src/i18n/locales/zh-Hant.json | 3 - src/layers/weather/display.test.mjs | 397 ++- src/layers/weather/imageryOrder.js | 17 - src/layers/weather/imageryOrder.test.mjs | 72 - src/layers/weather/index.js | 93 +- src/layers/weather/rasterTiles.js | 50 - src/layers/weather/rasterTiles.test.mjs | 156 -- src/layers/weather/render/display.js | 1 - src/layers/weather/rendering.js | 764 ------ src/layers/weather/shellFixture.mjs | 156 -- src/layers/weather/shellRendering.js | 343 +-- src/layers/weather/shellRendering.test.mjs | 889 ++---- src/layers/weather/weather.test.mjs | 2410 +++++++---------- .../wind/fieldUnavailableLocale.test.mjs | 2 +- src/layers/wind/fields.js | 14 + src/layers/wind/index.js | 63 +- src/layers/wind/index.test.mjs | 209 +- src/layers/wind/model.js | 22 + src/layers/wind/render/cesiumFlow.js | 13 + src/layers/wind/render/display.js | 23 +- src/layers/wind/render/display.test.mjs | 193 +- src/layers/wind/render/navaraFlow.js | 2 +- src/layers/wind/rendering.js | 247 +- src/layers/wind/rendering.test.mjs | 446 +-- src/layers/wind/staleInfo.test.mjs | 5 +- src/layers/wind/windProblemLocale.test.mjs | 5 +- src/layers/wind/windUpstreamReason.test.mjs | 8 +- src/testSupport/imageryDisplayEngines.mjs | 150 + 44 files changed, 1983 insertions(+), 4858 deletions(-) delete mode 100644 src/layers/weather/imageryOrder.js delete mode 100644 src/layers/weather/imageryOrder.test.mjs delete mode 100644 src/layers/weather/rasterTiles.js delete mode 100644 src/layers/weather/rasterTiles.test.mjs delete mode 100644 src/layers/weather/rendering.js delete mode 100644 src/layers/weather/shellFixture.mjs create mode 100644 src/layers/wind/render/cesiumFlow.js create mode 100644 src/testSupport/imageryDisplayEngines.mjs 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; + }, + }; +}