From 039b0828b7290140efd53af9b882fc2e7f00206b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 22:04:27 -0700 Subject: [PATCH 1/3] Let a weather source describe its own products A weather source can describe its own products (credit, units, coverage, legend) through an optional `display` object in each product manifest: source, credit, units, coverage, info, legend and extent. The weather layer uses each valid field in the row summary, legend, info text, coverage navigation, outside-coverage status, layer stats and imagery credit, and keeps today's built-in value for any field that is absent or invalid. Units and legend describe one scale, so they apply only as a valid pair; otherwise both keep their built-in values, and the radar info heading names the units in use. Text is single-line and length-capped. It rejects control characters (U+0000-U+001F, U+007F-U+009F), line and paragraph separators, and invisible or format characters: soft hyphen (U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C), Mongolian vowel separator (U+180E), zero-width and direction marks (U+200B-U+200F), bidi embedding and override (U+202A-U+202E), word joiner, invisible operators and bidi isolates (U+2060-U+2069), variation selectors (U+FE00-U+FE0F), the byte-order mark (U+FEFF) and tag characters (U+E0000-U+E007F). Legends need 2-16 stops whose labels are plain decimal numbers or short text and whose colours are #rrggbb. A number label must read back exactly as the legend shows it: within the safe integer range, with no leading zeros and no digits lost to rounding. Extents must be finite, in range and non-empty. Supplied source and units text is data: the layer marks it so the panels show it untranslated, while built-in values are translated as before. The imagery credit is escaped as plain text and follows a refreshed manifest even when the shown frame stays the same. A refresh changes only the credit: the shown frame keeps the snapshot it was staged from, so a later host switch restages it with its own time and bounds and the refreshed credit. Manifests without `display`, including the bundled nowCOAST ones, render exactly as before. --- docs/CURRENT-STATE.md | 27 + scripts/format-scope.json | 1 + scripts/package-boundaries.json | 2 + src/i18n/locales/de.json | 2 + src/i18n/locales/es.json | 2 + src/i18n/locales/fr.json | 2 + src/i18n/locales/it.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/pt.json | 2 + src/i18n/locales/ru.json | 2 + src/i18n/locales/uk.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/i18n/locales/zh-Hant.json | 2 + src/i18n/same-as-english.json | 6 + src/layers/weather/display.js | 153 +++++ src/layers/weather/display.test.mjs | 839 ++++++++++++++++++++++++++++ src/layers/weather/index.js | 134 +++-- src/layers/weather/rendering.js | 61 +- src/ui/layerPanel.js | 6 +- src/ui/layerStatusLocale.test.mjs | 18 + src/ui/railCardBlocks.js | 14 +- src/ui/weatherPanel.js | 1 + src/ui/weatherPanel.test.mjs | 24 + 23 files changed, 1243 insertions(+), 63 deletions(-) create mode 100644 src/layers/weather/display.js create mode 100644 src/layers/weather/display.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 015807ec2..766823550 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -475,6 +475,33 @@ not worldwide detections; units are strikes/km²/min ×10³. It is neither raw G flashes nor a live strike counter. Weather imagery orders scalar context below satellite, radar and lightning consistently across enable and history order. +A weather source can describe its own products (credit, units, coverage, +legend) through an optional `display` object in each product manifest: +`source` (≤60 characters, the row's source name), `credit` (≤200, the imagery +credit in Data attribution, shown as plain text and updated when a refreshed +manifest changes it), `units` (≤40), `coverage` (≤48, the coverage badge and +info line), `info` (≤600, the info tooltip), `legend` (2–16 `{ label, color }` +stops; label a plain decimal number or text, at most 16 characters either way, +a number reading back exactly as the legend shows it: within ±2^53−1, with +no leading zeros and no digits lost to rounding; color `#rrggbb`) and `extent` +(`{ west, south, east, north }` degrees, non-empty; west greater than east +crosses the antimeridian; used for coverage navigation and the +outside-coverage status). `units` and `legend` describe one scale and +apply only together: if either is absent or invalid, both keep the built-in +values, and the radar info heading names the units in use. Text must be +single-line, without control characters (U+0000–U+001F, U+007F–U+009F), line +or paragraph separators, or invisible and format characters: soft hyphen +(U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C), +Mongolian vowel separator (U+180E), zero-width and direction marks +(U+200B–U+200F), bidi embedding and override (U+202A–U+202E), word joiner, +invisible operators and bidi isolates (U+2060–U+2069), variation selectors +(U+FE00–U+FE0F), byte-order mark (U+FEFF) and tag characters +(U+E0000–U+E007F). Each other field is validated on its own; an absent or +invalid field keeps the built-in value, so the bundled nowCOAST manifests, +which carry no `display`, look exactly as before. Accepted source +and units text is source data and is shown untranslated. The static Data +attribution entries remain the application's `credits` list. + Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded same-origin provider. The status endpoint lacks browser CORS; each installation fetches keyless public sources at runtime, with no centralized ingestion service. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2522f91c7..2d1593a0e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -897,6 +897,7 @@ "scripts/qa-weather-swap.mjs", "scripts/qa-weather-teardown.mjs", "src/layers/weather/imageryHost.test.mjs", + "src/layers/weather/display.test.mjs", "src/sdr/adsbDecoder.test.mjs", "src/sdr/controller.test.mjs", "src/sdr/decoderDiagnostics.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7a5424365..b388f52df 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2641,6 +2641,7 @@ "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", @@ -3273,6 +3274,7 @@ "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", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b0d9ca463..da378518e 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1759,6 +1759,8 @@ "Contiguous US · gaps ≠ no rain": "USA ohne Alaska/Hawaii · Lücken ≠ kein Regen", "Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze", "Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³", + "Color: {units}": "Farbe: {units}", + "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert", "North America · infrared imagery": "Nordamerika · Infrarotbilder", "Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 643937da0..9fdc1062d 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1260,6 +1260,8 @@ "Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia", "Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales", "Color: strikes/km²/min ×10³": "Color: rayos/km²/min ×10³", + "Color: {units}": "Color: {units}", + "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso", "North America · infrared imagery": "Norteamérica · imágenes infrarrojas", "Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 18a14b1db..8e666b384 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1214,6 +1214,8 @@ "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie", "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", "Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", + "Color: {units}": "Couleur : {units}", + "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge", "Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index bcb6d73d3..792885d17 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1214,6 +1214,8 @@ "Contiguous US · gaps ≠ no rain": "USA contigui · lacune ≠ assenza di pioggia", "Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singoli fulmini", "Color: strikes/km²/min ×10³": "Colore: fulmini/km²/min ×10³", + "Color: {units}": "Colore: {units}", + "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo", "North America · infrared imagery": "Nord America · immagini all'infrarosso", "Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index ce0a26bfe..2bbd27649 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1182,6 +1182,8 @@ "Contiguous US · gaps ≠ no rain": "米国本土 · 欠測 ≠ 降水なし", "Americas + Pacific · not individual strikes": "南北アメリカ + 太平洋 · 個々の落雷ではありません", "Color: strikes/km²/min ×10³": "色:落雷数/km²/分 ×10³", + "Color: {units}": "色:{units}", + "RADAR · {units}": "レーダー · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ", "North America · infrared imagery": "北米 · 赤外画像", "Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 0a99f7e8e..2dbb8ca27 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1212,6 +1212,8 @@ "Contiguous US · gaps ≠ no rain": "EUA contíguos · lacunas ≠ sem chuva", "Americas + Pacific · not individual strikes": "Américas + Pacífico · não são raios individuais", "Color: strikes/km²/min ×10³": "Cor: raios/km²/min ×10³", + "Color: {units}": "Cor: {units}", + "RADAR · {units}": "RADAR · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso", "North America · infrared imagery": "América do Norte · imagens infravermelhas", "Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5a64abfb9..7e5a1e0f7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1278,6 +1278,8 @@ "Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя", "Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды", "Color: strikes/km²/min ×10³": "Цвет: разрядов/км²/мин ×10³", + "Color: {units}": "Цвет: {units}", + "RADAR · {units}": "РАДАР · {units}", "60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч", "North America · infrared imagery": "Северная Америка · ИК-снимки", "Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 282c5990a..889196689 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1229,6 +1229,8 @@ "Contiguous US · gaps ≠ no rain": "48 суміжних штатів США · прогалини ≠ немає дощу", "Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди", "Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³", + "Color: {units}": "Колір: {units}", + "RADAR · {units}": "РАДАР · {units}", "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год", "North America · infrared imagery": "Північна Америка · ІЧ-знімки", "Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 318c862bb..ba47f4322 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1156,6 +1156,8 @@ "Contiguous US · gaps ≠ no rain": "美国本土 · 数据缺失 ≠ 无降雨", "Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击", "Color: strikes/km²/min ×10³": "颜色:闪击次数/km²/分钟 ×10³", + "Color: {units}": "颜色:{units}", + "RADAR · {units}": "雷达 · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时", "North America · infrared imagery": "北美洲 · 红外影像", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d136fcd4d..9755ece95 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1151,6 +1151,8 @@ "Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨", "Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊", "Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³", + "Color: {units}": "顏色:{units}", + "RADAR · {units}": "雷達 · {units}", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時", "North America · infrared imagery": "北美洲 · 紅外影像", "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 57f2df8df..a0f2ced2b 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -93,6 +93,7 @@ "Plan", "Pop", "Punk", + "RADAR · {units}", "RADIO", "REC", "REGION", @@ -184,6 +185,7 @@ "CCTV", "CRS {value}°", "CRT", + "Color: {units}", "Country", "DEST ---°", "DEST {bearing}", @@ -235,6 +237,7 @@ "Pop", "Punk", "R&B", + "RADAR · {units}", "RADIO", "RANGE", "REC", @@ -400,6 +403,7 @@ "Plan", "Pop", "Punk", + "RADAR · {units}", "RADIO", "REC", "Radio", @@ -562,6 +566,7 @@ "PRECIP", "Pop", "Punk", + "RADAR · {units}", "RADIO", "REC", "RESET", @@ -765,6 +770,7 @@ "Paris", "Pop", "Punk", + "RADAR · {units}", "RANGE", "REC", "Reggae", diff --git a/src/layers/weather/display.js b/src/layers/weather/display.js new file mode 100644 index 000000000..13e117570 --- /dev/null +++ b/src/layers/weather/display.js @@ -0,0 +1,153 @@ +/** + * A weather source can describe its own products (credit, units, coverage, + * legend) in the optional `display` object of a product manifest. Each field + * is checked on its own: an absent or invalid field is dropped, and the layer + * keeps its built-in text for it. Units and legend describe the same scale, + * so they are kept only together. Accepted text is source data, shown as is + * rather than translated. + */ + +const TEXT_LIMITS = Object.freeze({ + source: 60, + credit: 200, + units: 40, + coverage: 48, + info: 600, +}); +const LEGEND_MIN = 2; +const LEGEND_MAX = 16; +const LEGEND_LABEL_MAX = 16; +// Control and line-break characters, plus invisible or format characters: +// soft hyphen, combining grapheme joiner, Arabic letter mark, Mongolian vowel +// separator, zero-width, direction-mark, bidi embedding/override/isolate, +// variation selector, byte-order and tag characters. +const CONTROL = + /[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufe00-\ufe0f\ufeff\u{e0000}-\u{e007f}]/u; +// A number label must read as a plain decimal, as the legend formats it. +const DECIMAL = /^-?\d+(?:\.\d+)?$/; +// A label the legend formats as a number: a plain decimal, maybe with "+". +const SCALE_NUMBER = /^-?(\d+(?:\.(\d+))?)\+?$/; +const COLOR = /^#[0-9a-f]{6}$/i; +const EMPTY = Object.freeze({}); +const cache = new WeakMap(); + +function text(value, max) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed && trimmed.length <= max && !CONTROL.test(trimmed) + ? trimmed + : undefined; +} + +/** + * Whether a label the legend formats as a number reads back exactly as given: + * within the safe integer range and with no digits lost to rounding, so + * '9999999999999999' (shown as 10000000000000000) and '007' are refused. + * Other text is shown as given. + */ +function exactScaleLabel(label) { + const match = SCALE_NUMBER.exec(label); + if (!match) return true; + const magnitude = Number(match[1]); + return ( + magnitude <= Number.MAX_SAFE_INTEGER && + magnitude.toFixed(match[2]?.length ?? 0) === match[1] + ); +} + +function number(value) { + if (!Number.isFinite(value)) return undefined; + const label = String(value); + return DECIMAL.test(label) && label.length <= LEGEND_LABEL_MAX + ? label + : undefined; +} + +function legend(value) { + if ( + !Array.isArray(value) || + value.length < LEGEND_MIN || + value.length > LEGEND_MAX + ) + return undefined; + const stops = []; + for (const stop of value) { + if (!stop || typeof stop !== 'object') return undefined; + const label = + typeof stop.label === 'number' + ? number(stop.label) + : text(stop.label, LEGEND_LABEL_MAX); + if (label === undefined || !exactScaleLabel(label)) return undefined; + if (typeof stop.color !== 'string' || !COLOR.test(stop.color)) + return undefined; + stops.push(Object.freeze([label, stop.color])); + } + return Object.freeze(stops); +} + +function extent(value) { + if (!value || typeof value !== 'object') return undefined; + const { west, south, east, north } = value; + if ( + ![west, south, east, north].every(Number.isFinite) || + west < -180 || + west > 180 || + east < -180 || + east > 180 || + south < -90 || + north > 90 || + south >= north || + // No width: one meridian, including 180 to -180 across the antimeridian. + west === east || + west - east >= 360 + ) + return undefined; + // West greater than east crosses the antimeridian. + return Object.freeze({ west, south, east, north }); +} + +/** + * The valid display fields of a weather manifest; `{}` when it has none. + * @param {object|null|undefined} manifest A validated weather snapshot. + * @returns {{ source?: string, credit?: string, units?: string, + * coverage?: string, info?: string, + * legend?: ReadonlyArray, + * extent?: { west: number, south: number, east: number, north: number } }} + */ +export function weatherDisplay(manifest) { + const display = manifest?.display; + if (!display || typeof display !== 'object' || Array.isArray(display)) + return EMPTY; + const known = cache.get(manifest); + if (known) return known; + const result = {}; + for (const [key, max] of Object.entries(TEXT_LIMITS)) { + const value = text(display[key], max); + if (value !== undefined) result[key] = value; + } + const stops = legend(display.legend); + if (stops && result.units !== undefined) result.legend = stops; + else delete result.units; + const box = extent(display.extent); + if (box) result.extent = box; + Object.freeze(result); + cache.set(manifest, result); + return result; +} + +/** Whether a longitude/latitude falls inside an extent, across the antimeridian too. */ +export function insideExtent({ west, south, east, north }, lon, lat) { + if (lat < south || lat > north) return false; + return west < east ? lon >= west && lon <= east : lon >= west || lon <= east; +} + +/** Plain text for a Cesium credit, which otherwise reads its text as HTML. */ +export function escapeCreditText(value) { + return value.replace( + /[&<>"']/g, + (ch) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + ch + ], + ); +} diff --git a/src/layers/weather/display.test.mjs b/src/layers/weather/display.test.mjs new file mode 100644 index 000000000..f867dc197 --- /dev/null +++ b/src/layers/weather/display.test.mjs @@ -0,0 +1,839 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { GeographicTilingScheme } from 'cesium'; +import { createWeatherLayer } from './index.js'; +import { createWeatherRendering } from './rendering.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']; +const base = { + schemaVersion: 1, + times, + latest: times[1], + bounds: { west: -180, south: -60, east: 180, north: 80 }, +}; +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; +const quiet = () => ({ + matches: false, + hidden: false, + addEventListener() {}, + removeEventListener() {}, +}); + +/** + * 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. + */ +async function shownLayer(id, manifest, params, view = {}) { + let time = null; + const flights = []; + const snapshots = []; + const manifests = { [manifest.product]: manifest }; + const layer = createWeatherLayer({ + id, + feed: { getSnapshot: async ({ product }) => manifests[product] }, + documentRef: quiet(), + eventTarget: quiet(), + matchMedia: () => quiet(), + createRendering: () => ({ + rehome() {}, + setAlpha() {}, + setHidden() {}, + updateSnapshot(snapshot) { + snapshots.push(snapshot); + }, + async setFrame(_snapshot, selected) { + time = selected; + return true; + }, + clear() { + time = null; + }, + 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 }, + }, + }); + if (params) layer.setParams(params); + layer.enable(); + await layer.update(); + await flush(); + layer.attachShellServices({ runNavigation: (navigate) => navigate() }); + return { layer, flights, manifests, snapshots }; +} + +const PRODUCTS = [ + ['weather-radar', 'radar', undefined], + ['weather-lightning', 'lightning', undefined], + ['weather-satellite', 'clouds-regional', undefined], + ['weather-satellite', 'clouds', { product: 'clouds' }], +]; +const INVALID = { + source: 42, + credit: '', + units: 'x'.repeat(41), + coverage: 'line one\nline two', + info: ['not', 'text'], + legend: [ + { label: '1', color: '#ffffff' }, + { label: '2', color: 'red' }, + ], + extent: { west: 0, south: 10, east: 20, north: 5 }, +}; + +const SATELLITE_TITLE = + 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.'; +const SATELLITE_CREDIT = + 'NOAA nowCOAST · NESDIS GOES / global satellite partners'; +// Today's built-in description of each product, written out rather than +// derived from the code under test. +const BUILT_IN = { + radar: { + units: 'dBZ', + coverage: 'CONUS', + legend: [ + ['10', '#00ecec'], + ['20', '#0000f6'], + ['30', '#00c800'], + ['40', '#ffff00'], + ['50', '#ff9000'], + ['60', '#dc0000'], + ['70', '#ff00ff'], + ['80', '#05ede0'], + ].map(([label, color]) => ({ + label, + color, + blurb: `${label} dBZ radar reflectivity`, + })), + heading: 'RADAR REFLECTIVITY · dBZ', + infoCoverage: 'Contiguous US · gaps ≠ no rain', + infoTitle: + 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.', + chip: 'View US radar', + focus: [-180, -60, 180, 80], + credit: 'NOAA nowCOAST · NWS/OAR MRMS', + }, + lightning: { + units: 'strikes/km²/min ×10³', + coverage: 'Americas + Pacific', + legend: [ + ['0.1', '#FFFFCC'], + ['1', '#FFA400'], + ['5', '#FF4500'], + ['10', '#FF0000'], + ['50', '#FF00FF'], + ['100', '#4000C0'], + ['200', '#00C7FF'], + ['300+', '#00FF00'], + ].map(([label, color]) => ({ + label, + color, + blurb: `${label} strikes/km²/min ×10³ (15-minute density)`, + })), + heading: 'LIGHTNING DENSITY · 15 min accumulation', + infoCoverage: + 'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³', + infoTitle: + 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.', + chip: 'View Americas & Pacific', + focus: [110, -25, 0, 80], + credit: 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360', + }, + 'clouds-regional': { + units: '', + coverage: 'North America', + legend: [], + heading: 'GOES INFRARED · ~5 min', + infoCoverage: 'North America · infrared imagery', + infoTitle: SATELLITE_TITLE, + chip: 'View coverage', + focus: [-180, -60, 180, 80], + credit: SATELLITE_CREDIT, + }, + clouds: { + units: '', + coverage: 'Global · 60°S–60°N', + legend: [], + heading: 'GLOBAL INFRARED · hourly', + infoCoverage: '60°S–60°N · typically 2–3 h delayed', + infoTitle: SATELLITE_TITLE, + chip: 'View coverage', + focus: [-180, -60, 180, 80], + credit: SATELLITE_CREDIT, + }, +}; + +/** Assert the built-in description of a product, field by field. */ +function assertBuiltIn(layer, product) { + const expected = BUILT_IN[product]; + const controls = layer.getRowControls(); + assert.equal(controls.summary.units, expected.units); + assert.equal(controls.summary.unitsVerbatim, undefined); + assert.equal(controls.summary.coverage, expected.coverage); + assert.deepEqual(controls.legend, expected.legend); + assert.ok( + controls.info.startsWith(`${expected.heading}\n`), + `${product} heading: ${controls.info}`, + ); + assert.ok( + controls.info.endsWith(`\n${expected.infoCoverage}`), + `${product} coverage: ${controls.info}`, + ); + assert.equal(controls.infoTitle, expected.infoTitle); + assert.equal( + controls.chips.find(({ id }) => id === 'coverage').label, + expected.chip, + ); + const stats = layer.getStats(); + assert.equal(stats.source, 'NOAA nowCOAST'); + assert.equal(stats.sourceVerbatim, undefined); +} + +for (const [id, product, params] of PRODUCTS) { + test(`${product}: a manifest without display metadata keeps today's description`, async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const { layer, flights } = await shownLayer( + id, + { ...base, product }, + params, + ); + assertBuiltIn(layer, product); + layer.setParams({ focus: true }); + assert.deepEqual( + flights[0], + Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus), + ); + layer.destroy(); + }); +} + +for (const [id, product, params] of PRODUCTS) { + test(`${product}: empty or wholly invalid display metadata changes nothing`, async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const plain = await shownLayer(id, { ...base, product }, params); + const empty = await shownLayer( + id, + { ...base, product, display: {} }, + params, + ); + const invalid = await shownLayer( + id, + { ...base, product, display: INVALID }, + params, + ); + const expected = plain.layer.getRowControls(); + assert.deepEqual(empty.layer.getRowControls(), expected); + assert.deepEqual(invalid.layer.getRowControls(), expected); + assertBuiltIn(invalid.layer, product); + for (const h of [plain, invalid]) h.layer.setParams({ focus: true }); + assert.deepEqual(invalid.flights, plain.flights); + for (const h of [plain, empty, invalid]) h.layer.destroy(); + }); +} + +const EXAMPLE = { + source: 'Example network', + credit: 'Example network · flash density', + units: 'flashes/km²/min', + coverage: '52°S–52°N', + info: 'Flash density from an example network. Not a safety warning.', + legend: [ + { label: 0.5, color: '#112233' }, + { label: '2', color: '#445566' }, + { label: '8+', color: '#AABBCC' }, + ], + extent: { west: -180, south: -52, east: 180, north: 52 }, +}; + +test('valid display metadata describes the product in the row, legend and stats', async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const { layer, flights } = await shownLayer('weather-lightning', { + ...base, + product: 'lightning', + display: EXAMPLE, + }); + const controls = layer.getRowControls(); + assert.equal(controls.summary.units, 'flashes/km²/min'); + assert.equal(controls.summary.unitsVerbatim, true); + assert.equal(controls.summary.coverage, '52°S–52°N'); + assert.deepEqual(controls.legend, [ + { label: '0.5', color: '#112233', blurb: '0.5 flashes/km²/min' }, + { label: '2', color: '#445566', blurb: '2 flashes/km²/min' }, + { label: '8+', color: '#AABBCC', blurb: '8+ flashes/km²/min' }, + ]); + assert.match(controls.info, /^LIGHTNING DENSITY · 15 min accumulation\n/); + assert.match(controls.info, /\n52°S–52°N\nColor: flashes\/km²\/min$/); + assert.equal(controls.infoTitle, EXAMPLE.info); + assert.equal( + controls.chips.find(({ id }) => id === 'coverage').label, + 'View coverage', + ); + const stats = layer.getStats(); + 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), + ); + layer.destroy(); +}); + +test('units and legend apply only as a valid pair', async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const legend = [ + { label: 1, color: '#000000' }, + { label: 10, color: '#ffffff' }, + ]; + for (const [id, product] of [ + ['weather-lightning', 'lightning'], + ['weather-radar', 'radar'], + ]) + for (const display of [ + { units: 'mm/h' }, + { legend }, + { units: 'mm/h', legend: [legend[0]] }, + { units: 'x'.repeat(41), legend }, + ]) { + const { layer } = await shownLayer(id, { ...base, product, display }); + assertBuiltIn(layer, product); + layer.destroy(); + } + const radar = await shownLayer('weather-radar', { + ...base, + product: 'radar', + display: { coverage: 'Europe', units: 'mm/h', legend }, + }); + const controls = radar.layer.getRowControls(); + assert.equal(controls.summary.coverage, 'Europe'); + assert.equal(controls.summary.units, 'mm/h'); + assert.equal(controls.summary.unitsVerbatim, true); + assert.deepEqual(controls.legend, [ + { label: '1', color: '#000000', blurb: '1 mm/h' }, + { label: '10', color: '#ffffff', blurb: '10 mm/h' }, + ]); + assert.match(controls.info, /^RADAR · mm\/h\n/); + assert.match(controls.info, /\nEurope$/); + radar.layer.destroy(); + const clouds = await shownLayer('weather-satellite', { + ...base, + product: 'clouds-regional', + display: { + units: 'K', + legend: [ + { label: 'warm', color: '#000000' }, + { label: 'cold', color: '#ffffff' }, + ], + }, + }); + assert.equal(clouds.layer.getRowControls().summary.units, 'K'); + assert.deepEqual(clouds.layer.getRowControls().legend, [ + { label: 'warm', color: '#000000', blurb: 'warm K' }, + { label: 'cold', color: '#ffffff', blurb: 'cold K' }, + ]); + clouds.layer.destroy(); +}); + +test('other display fields fall back on their own', async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const { layer } = await shownLayer('weather-lightning', { + ...base, + product: 'lightning', + display: { ...INVALID, coverage: 'Example coverage', source: 'Example' }, + }); + const controls = layer.getRowControls(); + assert.equal(controls.summary.coverage, 'Example coverage'); + assert.equal(controls.summary.units, BUILT_IN.lightning.units); + assert.deepEqual(controls.legend, BUILT_IN.lightning.legend); + assert.equal(controls.infoTitle, BUILT_IN.lightning.infoTitle); + assert.equal(layer.getStats().source, 'Example'); + layer.destroy(); +}); + +test('the map centre is checked against the source extent when one is given', async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const OUTSIDE = 'Map center is outside source coverage'; + const outside = (layer) => layer.getRowControls().info.includes(OUTSIDE); + const at = async (id, product, display, center) => { + const h = await shownLayer( + id, + { ...base, product, ...(display ? { display } : {}) }, + undefined, + { center }, + ); + const result = outside(h.layer); + if (result) + assert.equal( + h.layer.getRowControls().summary.status, + 'Map center outside coverage', + ); + h.layer.destroy(); + return result; + }; + // Without an extent: the manifest bounds, plus lightning's built-in gap. + assert.equal( + await at('weather-lightning', 'lightning', null, [50, 10]), + true, + ); + assert.equal( + await at('weather-lightning', 'lightning', null, [-100, 10]), + false, + ); + assert.equal(await at('weather-radar', 'radar', null, [50, 10]), false); + assert.equal(await at('weather-radar', 'radar', null, [50, -70]), true); + // An extent replaces the built-in gap. + const global = { extent: { west: -180, south: -52, east: 180, north: 52 } }; + assert.equal( + await at('weather-lightning', 'lightning', global, [50, 10]), + false, + ); + assert.equal( + await at('weather-lightning', 'lightning', global, [50, 60]), + true, + ); + const europe = { extent: { west: -10, south: 35, east: 30, north: 70 } }; + assert.equal(await at('weather-radar', 'radar', europe, [10, 50]), false); + assert.equal(await at('weather-radar', 'radar', europe, [-50, 50]), true); + assert.equal(await at('weather-radar', 'radar', europe, [10, 30]), true); + // West greater than east crosses the antimeridian. + const pacific = { extent: { west: 150, south: -30, east: -120, north: 60 } }; + for (const [center, expected] of [ + [[170, 0], false], + [[-150, 0], false], + [[0, 0], true], + [[-100, 0], true], + [[170, 70], true], + ]) + assert.equal( + await at('weather-radar', 'radar', pacific, center), + expected, + `centre ${center}`, + ); +}); + +test('switching to a product without display metadata restores its built-ins', async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const h = await shownLayer('weather-satellite', { + ...base, + product: 'clouds-regional', + display: EXAMPLE, + }); + h.manifests.clouds = { ...base, product: 'clouds' }; + assert.equal(h.layer.getStats().source, 'Example network'); + h.layer.setParams({ product: 'clouds' }); + await flush(); + assertBuiltIn(h.layer, 'clouds'); + h.layer.setParams({ product: 'clouds-regional' }); + await flush(); + assert.equal(h.layer.getStats().source, 'Example network'); + assert.equal(h.layer.getRowControls().summary.coverage, '52°S–52°N'); + h.layer.destroy(); +}); + +test('a refresh that drops display metadata restores the built-ins', async (t) => { + t.mock.method(Date, 'now', () => Date.parse(times[1])); + const h = await shownLayer('weather-lightning', { + ...base, + product: 'lightning', + display: EXAMPLE, + }); + assert.equal(h.layer.getStats().source, 'Example network'); + const plain = { ...base, product: 'lightning' }; + h.manifests.lightning = plain; + await h.layer.update(); + assertBuiltIn(h.layer, 'lightning'); + assert.equal(h.snapshots.at(-1), plain, 'the renderer sees the refresh'); + h.layer.destroy(); +}); + +test('display validation bounds text, legend shape, colours and extents', () => { + const read = (display) => weatherDisplay({ display }); + const scale = (legend) => read({ units: 'u', legend }).legend; + assert.deepEqual(read(undefined), {}); + assert.deepEqual(read(['credit']), {}); + assert.deepEqual(read({ coverage: ' Europe ' }), { coverage: 'Europe' }); + assert.deepEqual(read({ coverage: ' ' }), {}); + assert.deepEqual(read({ credit: 'a
b' }), {}); + assert.deepEqual(read({ credit: 'x'.repeat(200) }).credit.length, 200); + assert.deepEqual(read({ credit: 'x'.repeat(201) }), {}); + assert.deepEqual(read({ info: 'x'.repeat(601) }), {}); + // Invisible and direction-changing characters are rejected in every field. + for (const code of [ + 0x00ad, 0x034f, 0x061c, 0x180e, 0x200b, 0x200d, 0x200e, 0x200f, 0x202a, + 0x202d, 0x202e, 0x2060, 0x2066, 0x2069, 0xfe00, 0xfe0f, 0xfeff, 0xe0000, + 0xe0001, 0xe0041, 0xe007f, + ]) { + const ch = String.fromCodePoint(code); + const value = `a${ch}b`; + for (const key of ['source', 'credit', 'units', 'coverage', 'info']) + assert.equal( + read({ [key]: value, legend: [{ label: 1, color: '#000000' }] })[key], + undefined, + `${key} with U+${ch.codePointAt(0).toString(16)}`, + ); + assert.equal( + scale([ + { label: '0', color: '#000000' }, + { label: value, color: '#000000' }, + ]), + undefined, + ); + } + assert.equal(read({ coverage: 'a b · c–d' }).coverage, 'a b · c–d'); + const stop = (label, color = '#000000') => ({ label, color }); + assert.equal(scale([stop('1')]), undefined); + assert.equal( + scale(Array.from({ length: 17 }, (_, i) => stop(String(i)))), + undefined, + ); + for (const bad of [ + stop(Number.NaN), + stop(Infinity), + stop(0.1 + 0.2), + stop(1e21), + stop(1e-7), + stop(''), + stop('x'.repeat(17)), + stop('1', '#fff'), + stop('1', 'rgb(0,0,0)'), + stop('1', '#00000g'), + null, + '1', + ]) + assert.equal(scale([stop('0'), bad]), undefined); + // A number label must read back exactly as the legend formats it. + for (const bad of [ + '9999999999999999', + '9007199254740992', + '9007199254740992+', + '007', + '00.5', + 9007199254740992, + ]) + assert.equal(scale([stop('0'), stop(bad)]), undefined, String(bad)); + assert.deepEqual( + scale([ + stop('9007199254740991'), + stop(-900719925474099), + stop('-0.50'), + stop('1.25+'), + stop('123456.789012345'), + ]), + [ + ['9007199254740991', '#000000'], + ['-900719925474099', '#000000'], + ['-0.50', '#000000'], + ['1.25+', '#000000'], + ['123456.789012345', '#000000'], + ], + ); + for (const label of ['9007199254740991', '-0.50', '1.25+', '0.001']) + assert.equal(formatScaleLabel(label), label); + assert.equal(formatScaleLabel('9999999999999999'), '10000000000000000'); + assert.deepEqual(scale([stop(0), stop(-2.5), stop('1', '#ABCDEF')]), [ + ['0', '#000000'], + ['-2.5', '#000000'], + ['1', '#ABCDEF'], + ]); + // A legend needs its units and units need their legend. + assert.deepEqual(read({ units: 'u' }), {}); + assert.deepEqual(read({ legend: [stop(0), stop(1)] }), {}); + for (const bad of [ + { west: -10, south: 0, east: 10 }, + { west: -10, south: 0, east: 10, north: '5' }, + { west: -181, south: 0, east: 10, north: 5 }, + { west: -10, south: -91, east: 10, north: 5 }, + { west: -10, south: 5, east: 10, north: 5 }, + { west: -10, south: 6, east: 10, north: 5 }, + { west: 10, south: 0, east: 10, north: 5 }, + { west: 180, south: 0, east: -180, north: 5 }, + { west: -180, south: 0, east: -180, north: 5 }, + ]) + assert.equal(read({ extent: bad }).extent, undefined, JSON.stringify(bad)); + for (const good of [ + { west: -180, south: -90, east: 180, north: 90 }, + { west: 170, south: 0, east: -180, north: 5 }, + ]) + assert.deepEqual(read({ extent: good }).extent, good); + const wrap = read({ extent: { west: 110, south: -25, east: 0, north: 80 } }); + assert.equal(insideExtent(wrap.extent, 150, 10), true); + assert.equal(insideExtent(wrap.extent, -100, 10), true); + assert.equal(insideExtent(wrap.extent, 50, 10), false); + assert.equal(insideExtent(wrap.extent, -100, 81), false); + assert.equal( + escapeCreditText(`"A" & 'B'`), + '<b>"A" & 'B'</b>', + ); +}); + +function mockCanvas() { + const canvas = { width: 0, height: 0 }; + canvas.getContext = () => ({ + setTransform() {}, + drawImage() {}, + getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }), + putImageData() {}, + }); + 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, + }; + const options = { + cesium, + fetchImpl: async () => ({ + ok: true, + headers: new Headers(), + arrayBuffer: async () => new ArrayBuffer(1), + }), + decodeImage: async () => ({ width: 2048, height: 1024, close() {} }), + createCanvas: mockCanvas, + }; + const settle = async () => { + await flush(); + for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn(); + await flush(); + }; + return { viewer, providers, options, settle }; +} + +/** Show a frame of `manifest` and return the rendering and its globe. */ +async function shownFrame(manifest) { + const globe = globeViewer(); + const rendering = createWeatherRendering({ + viewer: globe.viewer, + ...globe.options, + }); + const shown = rendering.setFrame(manifest, times[1]); + await globe.settle(); + assert.equal(await shown, true); + return { ...globe, rendering }; +} + +const creditOf = (providers) => providers.at(-1).credit.html; + +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']) { + for (const display of [undefined, { credit: 42 }]) { + const h = await shownFrame({ + ...base, + product, + ...(display ? { display } : {}), + }); + assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product); + h.rendering.clear(); + } + const h = await shownFrame({ + ...base, + product, + display: { credit: 'Example & partners' }, + }); + assert.equal( + creditOf(h.providers), + 'Example <network> & partners', + ); + h.rendering.clear(); + } +}); + +test('a refreshed manifest updates the credit of the frame already shown', async () => { + for (const product of ['lightning', 'clouds']) { + const h = await shownFrame({ + ...base, + product, + display: { credit: 'First credit' }, + }); + assert.equal(creditOf(h.providers), 'First credit'); + const again = (display) => + h.rendering.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(await again(), true); + assert.equal(creditOf(h.providers), BUILT_IN[product].credit); + h.rendering.updateSnapshot({ + ...base, + product, + display: { credit: 'Third credit' }, + }); + assert.equal(creditOf(h.providers), 'Third credit'); + // Another product's manifest leaves this frame alone. + h.rendering.updateSnapshot({ + ...base, + product: 'radar', + display: { credit: 'Other' }, + }); + assert.equal(creditOf(h.providers), 'Third credit'); + h.rendering.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 first = { + ...base, + product: 'lightning', + display: { credit: 'First credit' }, + }; + const shown = rendering.setFrame(first, times[0]); + await globe.settle(); + assert.equal(await shown, true); + const refreshed = { + ...first, + times: [times[1]], + 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); + 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 restaged frame keeps the bounds it was staged from', + ); + assert.equal(creditOf(globe.providers), 'Second credit'); + rendering.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(); + let manifest = { + ...base, + product: 'lightning', + display: { credit: 'First credit' }, + }; + const layer = createWeatherLayer({ + id: 'weather-lightning', + feed: { getSnapshot: async () => manifest }, + documentRef: quiet(), + eventTarget: quiet(), + matchMedia: () => quiet(), + createRendering: (args) => + createWeatherRendering({ ...args, ...globe.options }), + }); + layer.init(globe.viewer); + layer.enable(); + const first = layer.update(); + await globe.settle(); + await first; + assert.equal(creditOf(globe.providers), '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'); + manifest = { ...base, product: 'lightning' }; + await layer.update(); + assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit); + layer.destroy(); +}); diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index 757764fe6..e318e3087 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { createWeatherRendering } from './rendering.js'; import { imageryHostStatus } from './imageryHost.js'; +import { weatherDisplay, insideExtent } from './display.js'; import { RADAR_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS, @@ -371,6 +372,8 @@ export function createWeatherLayer({ } manifest = snapshot; error = null; + // The shown frame may stay; its credit follows the refreshed manifest. + rendering?.updateSnapshot?.(snapshot); if (clock) { await clock.refresh(); if ( @@ -467,9 +470,11 @@ export function createWeatherLayer({ manifest?.bounds && runNavigation ) { - const b = lightning - ? { west: 110, south: -25, east: 0, north: 80 } - : manifest.bounds; + const b = + weatherDisplay(manifest).extent ?? + (lightning + ? { west: 110, south: -25, east: 0, north: 80 } + : manifest.bounds); runNavigation(() => viewer.camera.flyTo({ destination: cesium.Rectangle.fromDegrees( @@ -526,6 +531,8 @@ export function createWeatherLayer({ getRowControls() { const shared = clock?.getState(); const followLatest = isLatest(); + // Source-described text is data: shown as is, never translated. + const display = weatherDisplay(manifest); const missing = noFrame && shared?.mode === 'history' && @@ -592,7 +599,9 @@ export function createWeatherLayer({ lon > b.east || lat < b.south || lat > b.north || - (lightning && lon > 0 && lon < 110)); + (display.extent + ? !insideExtent(display.extent, lon, lat) + : lightning && lon > 0 && lon < 110)); let detail = time ? t('{state} · {time} · {lag}', { state: followLatest ? t('Observed') : t('History'), @@ -606,7 +615,9 @@ export function createWeatherLayer({ else if (relation === 'nearest') detail = t('{detail} · nearest', { detail }); const infoHeading = radar - ? t('RADAR REFLECTIVITY · dBZ') + ? display.units !== undefined + ? t('RADAR · {units}', { units: display.units }) + : t('RADAR REFLECTIVITY · dBZ') : lightning ? t('LIGHTNING DENSITY · 15 min accumulation') : product === 'clouds' @@ -631,13 +642,24 @@ export function createWeatherLayer({ : [t('Observation: unavailable'), loading ? t('loading') : ''] .filter(Boolean) .join(' · '); - const infoCoverage = radar - ? t('Contiguous US · gaps ≠ no rain') - : lightning - ? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}` - : product === 'clouds' - ? t('60°S–60°N · typically 2–3 h delayed') - : t('North America · infrared imagery'); + const coverageLine = + display.coverage ?? + (radar + ? t('Contiguous US · gaps ≠ no rain') + : lightning + ? t('Americas + Pacific · not individual strikes') + : product === 'clouds' + ? t('60°S–60°N · typically 2–3 h delayed') + : t('North America · infrared imagery')); + const infoCoverage = lightning + ? `${coverageLine}\n${ + display.units === undefined + ? t('Color: strikes/km²/min ×10³') + : t('Color: {units}', { units: display.units }) + }` + : coverageLine; + const stops = + display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []); const infoMotion = motion?.matches ? clock ? t('Reduced motion · history playback unavailable') @@ -651,13 +673,15 @@ export function createWeatherLayer({ : lightning ? t('Lightning density · 15 min') : t('Satellite clouds'), - coverage: radar - ? 'CONUS' - : lightning - ? t('Americas + Pacific') - : product === 'clouds' - ? t('Global · 60°S–60°N') - : t('North America'), + coverage: + display.coverage ?? + (radar + ? 'CONUS' + : lightning + ? t('Americas + Pacific') + : product === 'clouds' + ? t('Global · 60°S–60°N') + : t('North America')), shownTime: time, maxGapMinutes: maxGap() / 60_000, detail, @@ -674,7 +698,11 @@ export function createWeatherLayer({ : outside ? t('Map center outside coverage') : null), - units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '', + units: + display.units ?? + (radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : ''), + // Source units are data: the panel shows them untranslated. + ...(display.units !== undefined ? { unitsVerbatim: true } : {}), }, chips: [ ...(satellite @@ -723,43 +751,47 @@ export function createWeatherLayer({ })), { id: 'coverage', - label: radar - ? t('View US radar') - : lightning - ? t('View Americas & Pacific') - : t('View coverage'), + label: + display.coverage !== undefined || display.extent !== undefined + ? t('View coverage') + : radar + ? t('View US radar') + : lightning + ? t('View Americas & Pacific') + : t('View coverage'), disabled: !manifest || !runNavigation, params: { focus: true }, }, ], - legend: - radar || lightning - ? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({ - label: String(label), - color, - blurb: lightning - ? t('{value} strikes/km²/min ×10³ (15-minute density)', { - value: formatScaleLabel(label), - }) - : t('{value} dBZ radar reflectivity', { - value: formatScaleLabel(label), - }), - })) - : [], + legend: stops.map(([label, color]) => ({ + label: String(label), + color, + blurb: display.legend + ? `${formatScaleLabel(label)} ${display.units}` + : lightning + ? t('{value} strikes/km²/min ×10³ (15-minute density)', { + value: formatScaleLabel(label), + }) + : t('{value} dBZ radar reflectivity', { + value: formatScaleLabel(label), + }), + })), info: hostHidden ? shownHost : `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`, - infoTitle: lightning - ? t( - 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.', - ) - : radar + infoTitle: + display.info ?? + (lightning ? t( - 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.', + 'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.', ) - : t( - 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.', - ), + : radar + ? t( + 'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.', + ) + : t( + 'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.', + )), }; controls.summary.settings = [ ...(satellite @@ -805,6 +837,7 @@ export function createWeatherLayer({ listener = typeof value === 'function' ? value : null; }, getStats() { + const { source } = weatherDisplay(manifest); return { count: shownTime() ? 1 : 0, countLabel: isLatest() ? t('Observed') : t('History'), @@ -812,7 +845,10 @@ export function createWeatherLayer({ loading, error: error || rendering?.getDiagnostics().error || null, stale: Boolean(manifest?.stale || observationDelayed()), - source: 'NOAA nowCOAST', + ...(source === undefined + ? { source: 'NOAA nowCOAST' } + : // Source text is data: the panel shows it untranslated. + { source, sourceVerbatim: true }), observedAt: shownTime(), }; }, diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 0f2fad68b..05be9cebf 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -3,6 +3,7 @@ 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 { @@ -116,6 +117,35 @@ function createGlobeRendering({ 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, snapshot = frame.snapshot) { + const text = creditText(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. + */ + function updateSnapshot(snapshot) { + let changed = false; + for (const frame of new Set([current, incoming, ...retiring])) { + if (!frame || frame.product !== snapshot.product) continue; + if (frame.credit && setCredit(frame, snapshot)) changed = true; + } + if (changed) viewer.scene.requestRender(); + } + function remove(frame) { if (!frame) return; frame.closed = true; @@ -180,6 +210,7 @@ function createGlobeRendering({ return { rehome, cancelPrefetch, + updateSnapshot, async prefetch(snapshot, time, { infrared = 'filtered' } = {}) { let job; try { @@ -234,8 +265,10 @@ function createGlobeRendering({ current.product === snapshot.product && current.kind === kind && current.infrared === infrared - ) + ) { + updateSnapshot(snapshot); return true; + } lastError = null; const { west, south, east, north } = snapshot.bounds; const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north); @@ -342,14 +375,8 @@ function createGlobeRendering({ } : undefined, ); - const credit = new cesium.Credit( - snapshot.product === 'lightning' - ? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360' - : snapshot.product === 'radar' - ? 'NOAA nowCOAST · NWS/OAR MRMS' - : 'NOAA nowCOAST · NESDIS GOES / global satellite partners', - false, - ); + setCredit(frame); + const credit = frame.credit; const provider = global ? createRasterTileProvider({ cesium, @@ -373,6 +400,13 @@ function createGlobeRendering({ 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; @@ -601,6 +635,8 @@ export function createWeatherRendering({ if (restaging !== frame) return; restaging = null; if (!ok) onChange(); + else if (frame.latest && target === globe) + globe.updateSnapshot(frame.latest); }; target .setFrame(frame.snapshot, frame.time, { infrared: frame.infrared }) @@ -650,6 +686,13 @@ export function createWeatherRendering({ shown = { snapshot, time, infrared: options.infrared ?? 'filtered' }; 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) { + if (shown?.snapshot.product === snapshot.product) + shown = { ...shown, latest: snapshot }; + globe.updateSnapshot(snapshot); + }, setHidden(value) { hidden = Boolean(value); if (hidden) restaging = null; diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 37fc09161..a51d2923c 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -635,7 +635,11 @@ export class LayerPanel { const stats = layer.stats || {}; const feedState = layerFeedState(stats); const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]); - const source = sourceLabel(stats.source || layer.source); + // A layer marks source text that is data rather than a catalog name. + const source = + stats.sourceVerbatim === true && stats.source + ? stats.source + : sourceLabel(stats.source || layer.source); const lifecycleState = layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'); if (lifecycleState === 'enabling' || lifecycleState === 'disabling') { diff --git a/src/ui/layerStatusLocale.test.mjs b/src/ui/layerStatusLocale.test.mjs index e017b4b8a..400e6a032 100644 --- a/src/ui/layerStatusLocale.test.mjs +++ b/src/ui/layerStatusLocale.test.mjs @@ -87,3 +87,21 @@ for (const locale of ['es', 'ru']) { } }); } + +test('a source a layer marks as data is shown untranslated in the row', () => { + setLocale('ru'); + try { + const row = (stats) => + LayerPanel.prototype._buildMetaText({ + id: 'weather-lightning', + enabled: true, + stats: { count: 1, lastUpdate: Date.now(), ...stats }, + }); + assert.match(row({ source: 'Local ADS-B' }), /Локальный ADS-B/); + const verbatim = row({ source: 'Local ADS-B', sourceVerbatim: true }); + assert.match(verbatim, /Local ADS-B/); + assert.doesNotMatch(verbatim, /Локальный/); + } finally { + setLocale('en'); + } +}); diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index b63d0a6a0..4068f549e 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -16,9 +16,10 @@ const order = (parent, nodes) => parent.insertBefore(node, parent.children[i] || null); }); -// "960 — 1050+ гПа": the unit stays with the last number. -const scaleText = (low, high, units) => { - const unit = unitLabel(units); +// "960 — 1050+ гПа": the unit stays with the last number. Units a source +// supplied as data are shown as given. +const scaleText = (low, high, units, verbatim) => { + const unit = verbatim ? units || '' : unitLabel(units); const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`; return unit ? `${range}\u00a0${unit}` : range; }; @@ -309,7 +310,12 @@ export function createRailCardBlocks({ container, cardId, onParams }) { block.scale, 'textContent', visible - ? scaleText(legend.labels[0], legend.labels.at(-1), legend.units) + ? scaleText( + legend.labels[0], + legend.labels.at(-1), + legend.units, + legend.unitsVerbatim === true, + ) : '', ); } else if (props.type === 'actions') { diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js index 24ff83fd7..23f910d08 100644 --- a/src/ui/weatherPanel.js +++ b/src/ui/weatherPanel.js @@ -215,6 +215,7 @@ export function createWeatherPanel({ colors: legend.map(({ color }) => color), labels: legend.map(({ label }) => label), units: categorical ? '' : summary.units, + ...(summary.unitsVerbatim === true ? { unitsVerbatim: true } : {}), zeroIndex: legend.findIndex(({ label }) => label === '0'), }, }; diff --git a/src/ui/weatherPanel.test.mjs b/src/ui/weatherPanel.test.mjs index 70757c2f5..0d28f661d 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -752,3 +752,27 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir view.destroy(); layer.destroy(); }); +test('source-supplied legend units are shown as given; catalog units are translated', () => { + setLocale('ru'); + try { + const f = fixture(); + const view = createWeatherPanel(f); + const row = (summary) => ({ + ...radar, + summary: { ...radar.summary, units: 'km/h', ...summary }, + legend: [ + { label: '1', color: '#000000' }, + { label: '2', color: '#ffffff' }, + ], + }); + const scale = () => + f.find((n) => n.className === 'rail-card-scale').textContent; + view.update([row()]); + assert.equal(scale(), '1 — 2 км/ч'); + view.update([row({ unitsVerbatim: true })]); + assert.equal(scale(), '1 — 2 km/h'); + view.destroy(); + } finally { + setLocale('en'); + } +}); From 903f59302be07fa65423cb6f0df176cbef354de4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 22:42:45 -0700 Subject: [PATCH 2/3] Add the changelog entry for weather product display metadata --- changelog.d/1494-weather-product-display.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1494-weather-product-display.md diff --git a/changelog.d/1494-weather-product-display.md b/changelog.d/1494-weather-product-display.md new file mode 100644 index 000000000..3aabc5b6a --- /dev/null +++ b/changelog.d/1494-weather-product-display.md @@ -0,0 +1,4 @@ +- Let a weather source describe its own products through an optional + `display` object in each manifest (source, credit, units, coverage, info, + legend and extent). Each valid field replaces the built-in value, and + manifests without it render as before (samehkhamis, #1494). From 70494698bc9717001022c17d1dd1f371cb562fbd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 22:58:06 -0700 Subject: [PATCH 3/3] Keep a refreshed weather credit through frame installs and host switches A refreshed manifest could lose its credit in two ways. A frame still loading when the refresh arrived had no credit yet, so the refresh was skipped and the frame installed with the credit of the snapshot it was staged from. And a host switch restaged the shown frame, then re-applied the refreshed manifest captured when the restage began, overwriting any newer refresh that arrived while it loaded. Each frame now remembers the latest refreshed manifest of its product, whether or not its imagery is installed yet, and takes its credit from it, falling back to the snapshot it was staged from. That snapshot still supplies the time and bounds. A restage passes the newest refreshed manifest to the new frame up front instead of re-applying a captured one afterwards, and a refresh that arrives while a frame loads is kept for a later restage. --- src/layers/weather/display.test.mjs | 100 ++++++++++++++++++++++++++++ src/layers/weather/rendering.js | 49 +++++++++++--- 2 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/layers/weather/display.test.mjs b/src/layers/weather/display.test.mjs index f867dc197..e852a1ae8 100644 --- a/src/layers/weather/display.test.mjs +++ b/src/layers/weather/display.test.mjs @@ -805,6 +805,106 @@ test('a refreshed manifest leaves the shown snapshot for a host switch to restag rendering.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 first = { + ...base, + product: 'lightning', + display: { credit: 'First credit' }, + }; + const shown = rendering.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' } }); + await globe.settle(); + assert.equal(globe.providers.length, 2); + assert.equal(creditOf(globe.providers), 'Third credit'); + rendering.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 first = { + ...base, + product: 'lightning', + display: { credit: 'First credit' }, + }; + const shown = rendering.setFrame(first, times[0]); + rendering.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); + await globe.settle(); + assert.equal(globe.providers.length, 2); + assert.equal(creditOf(globe.providers), 'Second credit'); + rendering.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 first = { ...base, product, display: { credit: 'First credit' } }; + const shown = rendering.setFrame(first, times[1]); + rendering.updateSnapshot({ + ...first, + display: { credit: 'Second credit' }, + }); + await globe.settle(); + assert.equal(await shown, true); + assert.equal(creditOf(globe.providers), 'Second credit', product); + rendering.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(); diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 05be9cebf..629101693 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -127,21 +127,24 @@ function createGlobeRendering({ ? 'NOAA nowCOAST · NWS/OAR MRMS' : 'NOAA nowCOAST · NESDIS GOES / global satellite partners'; } - function setCredit(frame, snapshot = frame.snapshot) { - const text = creditText(snapshot); + 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. + * 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; - if (frame.credit && setCredit(frame, snapshot)) changed = true; + frame.creditSnapshot = snapshot; + if (frame.credit && setCredit(frame)) changed = true; } if (changed) viewer.scene.requestRender(); } @@ -251,7 +254,11 @@ function createGlobeRendering({ if (job && prefetchJob === job) prefetchJob = null; } }, - async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) { + async setFrame( + snapshot, + time, + { signal, infrared = 'filtered', creditSnapshot } = {}, + ) { signal?.throwIfAborted(); cancelPrefetch(); rehome(); @@ -266,7 +273,7 @@ function createGlobeRendering({ current.kind === kind && current.infrared === infrared ) { - updateSnapshot(snapshot); + updateSnapshot(creditSnapshot ?? snapshot); return true; } lastError = null; @@ -275,6 +282,7 @@ function createGlobeRendering({ const global = snapshot.product === 'clouds'; const frame = { snapshot, + creditSnapshot, time, infrared, mosaic: global @@ -608,6 +616,8 @@ export function createWeatherRendering({ 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 = () => { @@ -635,11 +645,14 @@ export function createWeatherRendering({ if (restaging !== frame) return; restaging = null; if (!ok) onChange(); - else if (frame.latest && target === globe) - globe.updateSnapshot(frame.latest); }; + // 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 }) + .setFrame(frame.snapshot, frame.time, { + infrared: frame.infrared, + ...(target === globe ? { creditSnapshot: shown?.latest } : {}), + }) .then(settle, () => settle(false)); } function switchHost() { @@ -681,14 +694,27 @@ export function createWeatherRendering({ switchHost(); restaging = null; const target = active === 'shell' ? shellFor(snapshot.product) : globe; + const before = refreshed; const ok = await target.setFrame(snapshot, time, options); - if (ok) - shown = { snapshot, time, infrared: options.infrared ?? 'filtered' }; + 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); @@ -705,6 +731,7 @@ export function createWeatherRendering({ clear() { restaging = null; shown = null; + refreshed = null; hidden = false; shell?.clear(); shell = null;