From d1bbfdbca5736900bbdbf54a8d57d773b7ec3191 Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:10:52 +0530 Subject: [PATCH 01/10] =?UTF-8?q?fix(draw):=20show=20a=209,999.6=20m=C2=B2?= =?UTF-8?q?=20area=20as=201.0=20ha,=20not=2010000=20m=C2=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draw hint picked m², ha or km² from the raw area and then rounded, so 9,999.5 m² up to a hectare read "10000 m²" and about 999,500 m² up to a square kilometre read "100.0 ha". It now rounds first, as the line measure already does. --- docs/CURRENT-STATE.md | 1 + src/annotations/drawMode.js | 9 ++++++--- src/annotations/drawMode.test.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..2578eb84a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5350,6 +5350,7 @@ request viewbox and continues searching without that geographic preference. - Drawn-line distance labels select metres or kilometres after rounding to the nearest metre, so a 999.5-metre line displays as `1.0 km` rather than `1000 m`. - **Measurements and centres are computed on unwrapped longitudes** (`unwrapLongitudes` / `wrapLongitude` in `drawMode.js`, used by `ringAreaM2`, `ringCentroid` and the engine's manual-area anchor). A ~50,000 m² rectangle straddling the antimeridian reads its 0.002° width as 359.998° under raw arithmetic: it measured thousands of square kilometres and anchored on the Greenwich meridian, half a world from the shape. Path length was already safe — haversine is periodic in the longitude delta — and a test pins that so a "fix" cannot break it. - **Bounds.** One shape holds at most `MAX_VERTICES` = 512 points; two clicks within 0.5 m are one vertex (that is what makes a double-click finish without leaving a stray point); a vertex that is not a finite on-globe coordinate is refused. A shape that encloses nothing is refused rather than placed: three collinear points are not an area and a line under a metre long is not a line, and the hint says which. +- Drawn-area measures round before choosing m², ha or km², so 9,999.6 m² displays as `1.0 ha`, not `10000 m²`, and 999,960 m² as `1.00 km²`, not `100.0 ha`. - Pointer ownership: while a session is open the tool holds the single pointer claim (`claimPointer('draw')`, `src/data/inputOwnership.js`) and **every** ambient scene selection handler — the shared tracking click gesture behind flights, military and CCTV, plus vessels, satellites, launches, fires, installations, bikeshare, radio, submarine cables, the bundled GeoJSON layers and the CCTV calibration gizmo — returns before picking. A vertex over an aircraft is a vertex, not a selection. - **The claim is a LEASE.** `claimPointer(owner)` returns an opaque token or `null`; `releasePointer(lease)` takes that token. A second claim fails even under the same owner name, because two live instances of one tool — an old one mid-teardown and its replacement — are two owners; releasing by name let the old instance free the claim its successor was holding, and every layer started selecting through the live session's vertices. A superseded lease now releases nothing. - **Cesium's own click actions are borrowed too.** The Viewer binds a selecting LEFT_CLICK and a tracking LEFT_DOUBLE_CLICK on its own handler, outside every layer and therefore outside the shared claim — guarding the layers alone still left a drawn vertex selecting the entity under it. Both are removed for the session and restored, as the same function objects, when it ends or the tool is destroyed. diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index 538c2e5da..93f75dbdc 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -215,9 +215,12 @@ export function formatMeasure(session) { } if (session.shape === 'area') { const m2 = ringAreaM2(session.vertices); - if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`; - if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`; - return `${Math.round(m2)} m²`; + // Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²". + const whole = Math.round(m2); + const ha = (m2 / 1e4).toFixed(1); + if (whole < 1e4) return `${whole} m²`; + if (Number(ha) < 100) return `${ha} ha`; + return `${(m2 / 1e6).toFixed(2)} km²`; } return ''; } diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index be9894dda..51e3b854e 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -345,3 +345,30 @@ test('a line that rounds up to a kilometre reads 1.0 km, not 1000 m', () => { assert.equal(formatMeasure(line), '1.0 km'); assert.match(drawHint(line), /^1\.0 km · /); }); + +test('an area that rounds up to the next unit reads in that unit', () => { + // A square on the equator whose shoelace area is about `m2`. + const square = (m2) => { + const side = Math.sqrt(m2) / 111320; + const area = createDrawSession('area'); + for (const [lon, lat] of [ + [0, 0], + [side, 0], + [side, side], + [0, side], + ]) + addVertex(area, { lon, lat }); + return area; + }; + const almostHectare = square(9999.6); + const metres = ringAreaM2(almostHectare.vertices); + assert.ok( + metres >= 9999.5 && metres < 1e4, + `area should round to 10000 m², got ${metres}`, + ); + assert.equal(formatMeasure(almostHectare), '1.0 ha'); + assert.match(drawHint(almostHectare), /^1\.0 ha · /); + assert.equal(formatMeasure(square(9999.4)), '9999 m²'); + assert.equal(formatMeasure(square(999_960)), '1.00 km²'); + assert.equal(formatMeasure(square(999_400)), '99.9 ha'); +}); From 76a3d47e563d03f0d90415ae67d65c879effc18f Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:42 +0530 Subject: [PATCH 02/10] docs(changelog): add #1446 fragment --- changelog.d/1446-draw-area-units.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1446-draw-area-units.md diff --git a/changelog.d/1446-draw-area-units.md b/changelog.d/1446-draw-area-units.md new file mode 100644 index 000000000..ef82d6628 --- /dev/null +++ b/changelog.d/1446-draw-area-units.md @@ -0,0 +1,3 @@ +- Show a drawn area of 9,999.5 m² or more as 1.0 ha, and of 999,500 m² or + more as 1.00 km². The draw hint picked the unit before rounding, so it read + "10000 m²" and "100.0 ha" (Aditya, @Rohilalala, #1446). From da2a3c3379461c335a211fccd5dd99f248e5f819 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:08:10 -0700 Subject: [PATCH 03/10] fix(sky): read the Milky Way faces by file path in the paint-out test The test passed URL.pathname to sharp, which is /D:/... on Windows, so the faces were not found and npm test failed on every Windows clone. fileURLToPath gives a path that works on every platform. Co-Authored-By: Claude Opus 5.5 --- src/tooling/skyAssets.paintOut.test.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tooling/skyAssets.paintOut.test.mjs b/src/tooling/skyAssets.paintOut.test.mjs index ddb57589a..78a91519e 100644 --- a/src/tooling/skyAssets.paintOut.test.mjs +++ b/src/tooling/skyAssets.paintOut.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import sharp from 'sharp'; import { CUBE_FACES, @@ -24,7 +25,7 @@ test('painted-out planets leave no disc brighter than the sky around them', asyn })); for (const face of CUBE_FACES) { const { data, info } = await sharp( - new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY).pathname, + fileURLToPath(new URL(`milkyway_${FACE_FILE_SUFFIX[face]}.webp`, SKY)), ) .raw() .toBuffer({ resolveWithObject: true }); From e1bbbcec5a4e54ed140c53370bdacd67cf52bfd7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 14:31:06 -0700 Subject: [PATCH 04/10] Let a GNSS integrity source set the layer's evidence window The catalog now passes the gnss-integrity source's windowMs to the layer. A missing, non-finite or non-positive value keeps the 30-minute default. The row text now names the window the layer uses instead of a fixed 30 min. --- src/app/constructCatalog.js | 1 + src/app/constructCatalog.test.mjs | 39 +++++++++++++++++++++++++++++++ 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/layers/gnss/index.js | 11 +++++++-- 13 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 59edc02ad..198f91dff 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -126,6 +126,7 @@ export function createApplicationCatalog({ }), createApplicationGnssIntegrity({ source: sources['gnss-integrity'], + windowMs: sources['gnss-integrity'].windowMs, }), createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationAlpr({ surface, source: sources.alpr }), diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index eb9e88d31..5593d6fc0 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -193,3 +193,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership', /Mapillary source/, ); }); + +test('a GNSS integrity source can set the layer evidence window', (t) => { + const lifetime = new AbortController(); + t.after(() => lifetime.abort()); + const gnssLayer = (descriptor) => { + const sources = fixtureSources([], []); + sources['gnss-integrity'] = { + getSnapshot: async () => ({}), + ...descriptor, + }; + return createApplicationCatalog({ + sources, + signal: lifetime.signal, + surface: fixtureSurface(lifetime.signal), + }).get('gnss-integrity'); + }; + const shows = (layer, minutes) => { + assert.equal(layer.provenance.window.minutes, minutes); + const { info, legend } = layer.getRowControls(); + assert.match(info, new RegExp(`visited views over ${minutes} min`)); + assert.match(legend[0].blurb, new RegExp(`over ${minutes} minutes`)); + }; + shows(gnssLayer({ windowMs: 65 * 60_000 }), 65); + shows(gnssLayer({}), 30); + for (const windowMs of [ + undefined, + null, + NaN, + Infinity, + 0, + -60_000, + '3600000', + ]) + assert.equal( + gnssLayer({ windowMs }).provenance.window.minutes, + 30, + `${String(windowMs)} falls back to the default`, + ); +}); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 938fb20bc..b0d9ca463 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1458,7 +1458,7 @@ "Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %", "Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %", "GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über 30 min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min", "Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC", "Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5b893a0cb..643937da0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2158,7 +2158,7 @@ "Low-accuracy score 2–10%": "Índice de baja precisión 2–10%", "Low-accuracy score > 10%": "Índice de baja precisión > 10%", "GNSS integrity source unavailable": "Fuente de integridad GNSS no disponible", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en 30 min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en {minutes} min", "Retrying automatically at {time} UTC": "Reintento automático a las {time} UTC", "Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar o hacer zoom y una vez por minuto. El feed público cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no lo convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 895b121eb..18a14b1db 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -771,7 +771,7 @@ "Low-accuracy score 2–10%": "Indice de faible précision 2–10 %", "Low-accuracy score > 10%": "Indice de faible précision > 10 %", "GNSS integrity source unavailable": "Source d'intégrité GNSS indisponible", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur 30 min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Échantillon régional · rayon de 250 NM · cellules des vues visitées sur {minutes} min", "Retrying automatically at {time} UTC": "Nouvel essai automatique à {time} UTC", "Retrying automatically after provider cooldown": "Nouvel essai automatique après la pause du fournisseur", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Mise à jour automatique après un déplacement ou un zoom, et une fois par minute. Le flux public couvre un cercle de 250 milles nautiques autour du centre de la vue ; dézoomer n'en fait pas un instantané mondial. Les cellules vides sont hors de la couverture échantillonnée ou manquent d'observations.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index ccce863d4..bcb6d73d3 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -771,7 +771,7 @@ "Low-accuracy score 2–10%": "Indice di bassa precisione 2–10%", "Low-accuracy score > 10%": "Indice di bassa precisione > 10%", "GNSS integrity source unavailable": "Fonte di integrità GNSS non disponibile", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi 30 min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Campione regionale · raggio di 250 NM · celle dalle viste visitate negli ultimi {minutes} min", "Retrying automatically at {time} UTC": "Nuovo tentativo automatico alle {time} UTC", "Retrying automatically after provider cooldown": "Nuovo tentativo automatico dopo la pausa del provider", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Si aggiorna automaticamente dopo uno spostamento o uno zoom e una volta al minuto. Il feed pubblico copre un cerchio di 250 miglia nautiche attorno al centro della vista; allontanare lo zoom non lo rende un'istantanea mondiale. Le celle vuote sono fuori dalla copertura campionata o hanno osservazioni insufficienti.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index edd9c079f..ce0a26bfe 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -749,7 +749,7 @@ "Low-accuracy score 2–10%": "低精度スコア 2–10%", "Low-accuracy score > 10%": "低精度スコア > 10%", "GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "地域サンプル · 半径250海里 · 30分以内に表示したビューのセル", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · {minutes}分以内に表示したビューのセル", "Retrying automatically at {time} UTC": "{time} UTCに自動で再試行します", "Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径250海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index c3f4bcf90..0a99f7e8e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -769,7 +769,7 @@ "Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%", "Low-accuracy score > 10%": "Índice de baixa precisão > 10%", "GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Amostra regional · raio de 250 NM · células das vistas visitadas em 30 min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das vistas visitadas em {minutes} min", "Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC", "Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index fe8338c3f..5a64abfb9 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2192,7 +2192,7 @@ "Low-accuracy score 2–10%": "Доля неточных 2–10%", "Low-accuracy score > 10%": "Доля неточных > 10%", "GNSS integrity source unavailable": "Источник целостности GNSS недоступен", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Региональная выборка · радиус 250 мор. миль · ячейки из просмотренных видов за 30 мин", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Региональная выборка · радиус 250 мор. миль · ячейки из просмотренных видов за {minutes} мин", "Retrying automatically at {time} UTC": "Автоматический повтор в {time} UTC", "Retrying automatically after provider cooldown": "Автоматический повтор после паузы провайдера", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Обновляется автоматически после сдвига или масштабирования карты и раз в минуту. Публичный поток данных охватывает круг радиусом 250 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 37e55b961..282c5990a 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -781,7 +781,7 @@ "Low-accuracy score 2–10%": "Показник неточності 2–10%", "Low-accuracy score > 10%": "Показник неточності > 10%", "GNSS integrity source unavailable": "Джерело даних про цілісність GNSS недоступне", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "Регіональна вибірка · радіус 250 мор. миль · комірки з переглянутих ділянок карти за 30 хв", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Регіональна вибірка · радіус 250 мор. миль · комірки з переглянутих ділянок карти за {minutes} хв", "Retrying automatically at {time} UTC": "Автоматичний повтор о {time} UTC", "Retrying automatically after provider cooldown": "Автоматичний повтор після паузи постачальника", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Оновлюється автоматично після зсуву чи масштабування карти та раз на хвилину. Публічний потік даних охоплює коло радіусом 250 морських миль навколо центру карти; у разі віддалення карта не стає знімком усього світу. Порожні комірки — поза охопленням вибірки або з недостатньою кількістю спостережень.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8dc28b308..318c862bb 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -726,7 +726,7 @@ "Low-accuracy score 2–10%": "低精度评分 2–10%", "Low-accuracy score > 10%": "低精度评分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性数据源不可用", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "区域采样 · 半径 250 海里 · 30 分钟内已访问视图中的网格", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内已访问视图中的网格", "Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试", "Retrying automatically after provider cooldown": "服务商冷却结束后自动重试", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c82131223..d136fcd4d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -721,7 +721,7 @@ "Low-accuracy score 2–10%": "低精度評分 2–10%", "Low-accuracy score > 10%": "低精度評分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性數據源不可用", - "Regional sample · 250 nm radius · cells from visited views over 30 min": "區域採樣 · 半徑 250 海里 · 30 分鐘內已訪問視圖中的網格", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內已訪問視圖中的網格", "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", "Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index f3a4102f8..ba0e9ff5d 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({ viewAnchor = defaultGnssViewAnchor, now = () => Date.now(), monotonicNow = () => performance.now(), - windowMs = GNSS_WINDOW_MS, + windowMs: requestedWindowMs, } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('GNSS integrity layer requires a snapshot source'); + // A source may set its own evidence window; anything but a positive finite + // duration keeps the default. + const windowMs = + Number.isFinite(requestedWindowMs) && requestedWindowMs > 0 + ? requestedWindowMs + : GNSS_WINDOW_MS; let _viewer = null; let _primitives = null; let _request = null; @@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({ chips: [], info: t( - 'Regional sample · 250 nm radius · cells from visited views over 30 min', + 'Regional sample · 250 nm radius · cells from visited views over {minutes} min', + { minutes: formatNumber(provenance.window.minutes) }, ) + (_lastError && _retryAt ? ` · ${translateFeedError(_lastError)} · ${ From cb6fd7568a8a4993cc835b7a2f96a206fd0f4991 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Sat, 31 Oct 2026 23:18:42 +0000 Subject: [PATCH 05/10] =?UTF-8?q?test(draw):=20pin=20the=20shoelace=20area?= =?UTF-8?q?=20in=20the=20km=C2=B2=20and=20ha=20rounding=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/annotations/drawMode.test.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/annotations/drawMode.test.mjs b/src/annotations/drawMode.test.mjs index 51e3b854e..8018b4527 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -369,6 +369,19 @@ test('an area that rounds up to the next unit reads in that unit', () => { assert.equal(formatMeasure(almostHectare), '1.0 ha'); assert.match(drawHint(almostHectare), /^1\.0 ha · /); assert.equal(formatMeasure(square(9999.4)), '9999 m²'); - assert.equal(formatMeasure(square(999_960)), '1.00 km²'); - assert.equal(formatMeasure(square(999_400)), '99.9 ha'); + // Pin the shoelace area too, so a geodesy change fails here and not as a unit. + const almostKm2 = square(999_960); + const nearKm2 = ringAreaM2(almostKm2.vertices); + assert.ok( + nearKm2 >= 999_500 && nearKm2 < 1e6, + `area should round to 100.0 ha, got ${nearKm2}`, + ); + assert.equal(formatMeasure(almostKm2), '1.00 km²'); + const under = square(999_400); + const underM2 = ringAreaM2(under.vertices); + assert.ok( + underM2 >= 998_500 && underM2 < 999_500, + `area should round to 99.9 ha, got ${underM2}`, + ); + assert.equal(formatMeasure(under), '99.9 ha'); }); From 691f27387b988c0a2cecd5e95657b86848f4a733 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 16:41:46 -0700 Subject: [PATCH 06/10] Add the changelog entry for the GNSS source window --- changelog.d/1491-gnss-source-window.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1491-gnss-source-window.md diff --git a/changelog.d/1491-gnss-source-window.md b/changelog.d/1491-gnss-source-window.md new file mode 100644 index 000000000..108aa50b2 --- /dev/null +++ b/changelog.d/1491-gnss-source-window.md @@ -0,0 +1,3 @@ +- Let a GNSS integrity source set the layer's evidence window (`windowMs`). A + missing or invalid value keeps the 30-minute default, and the layer row now + names the window in use instead of a fixed "30 min" (samehkhamis, #1491). From 20ad658f6caa5a904608af82333b6f3a593745c6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 17:50:16 -0700 Subject: [PATCH 07/10] Resolve localhost to both loopbacks in the real connect-failure test The FIRMS test that checks a real all-addresses connect failure relied on the system resolving localhost to both ::1 and 127.0.0.1. On a host whose localhost resolves only to 127.0.0.1, fetch makes a single attempt, gets a plain ECONNREFUSED instead of an AggregateError, and the test fails. Mock dns.lookup for that fetch to return both loopback addresses, so the real fetch still attempts both families and the error shape check stays deterministic. Assert the mock was used, so a runtime that stops resolving through dns.lookup fails loudly instead of silently testing something else. --- src/data/firmsProxy.test.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/data/firmsProxy.test.mjs b/src/data/firmsProxy.test.mjs index 9ed35ba24..14668c825 100644 --- a/src/data/firmsProxy.test.mjs +++ b/src/data/firmsProxy.test.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import test from 'node:test'; import { EventEmitter } from 'node:events'; import dns from 'node:dns/promises'; +import dnsLookup from 'node:dns'; import https from 'node:https'; import net from 'node:net'; import { Readable } from 'node:stream'; @@ -258,7 +259,8 @@ test('the IPv4 retry answering an invalid reason phrase rejects instead of crash // The fixtures above copy Node's error shape; check it against a real fetch so // a Node or undici change that stops matching fails here instead of silently -// disabling the retry. A closed port on `localhost` refuses both addresses. +// disabling the retry. A closed port refuses both loopback addresses; resolve +// `localhost` to both here, since some hosts map it to 127.0.0.1 only. test('a real all-addresses connect failure is recognised as address selection', async (t) => { const probe = net.createServer(); try { @@ -272,7 +274,10 @@ test('a real all-addresses connect failure is recognised as address selection', const v4 = net.createServer(); await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve)); await new Promise((resolve) => v4.close(resolve)); + const loopback = [{ address: '::1', family: 6 }, { address: '127.0.0.1', family: 4 }]; + const lookup = t.mock.method(dnsLookup, 'lookup', (_host, _options, callback) => callback(null, loopback)); const err = await fetch(`http://localhost:${port}/`).then(() => null, (error) => error); + assert.equal(lookup.mock.callCount(), 1, 'fetch resolves localhost through dns.lookup'); assert.ok(err, 'a closed port refuses the connection'); assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err)); assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/); From 039b0828b7290140efd53af9b882fc2e7f00206b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 22:04:27 -0700 Subject: [PATCH 08/10] 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 09/10] 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 10/10] 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;