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). 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). 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 85d5f52a2..92121c526 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. @@ -5457,6 +5484,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/scripts/format-scope.json b/scripts/format-scope.json index c86531fc3..c6ae4b493 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -901,6 +901,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 dc53bb66b..3ec09ae6d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2874,6 +2874,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", @@ -3572,6 +3573,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/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..8018b4527 100644 --- a/src/annotations/drawMode.test.mjs +++ b/src/annotations/drawMode.test.mjs @@ -345,3 +345,43 @@ 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²'); + // 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'); +}); 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/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/); diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index c1b7db3e5..57a27265b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1453,7 +1453,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.", @@ -1754,6 +1754,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 6e3acde69..0e09fab79 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1257,6 +1257,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", @@ -2155,7 +2157,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 2af1499e8..e6a048db7 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -769,7 +769,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.", @@ -1212,6 +1212,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 aed8d0003..ce1d7075d 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -769,7 +769,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.", @@ -1212,6 +1212,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 3d8e084ed..686eca0dc 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -747,7 +747,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海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。", @@ -1180,6 +1180,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 7af4ae72c..1b7a16629 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -767,7 +767,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.", @@ -1210,6 +1210,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 a7d2e3483..5b7728f03 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1275,6 +1275,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": "Уменьшение движения · воспроизведение истории недоступно", @@ -2189,7 +2191,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 47326d900..9fd87bb9c 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -779,7 +779,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 морських миль навколо центру карти; у разі віддалення карта не стає знімком усього світу. Порожні комірки — поза охопленням вибірки або з недостатньою кількістю спостережень.", @@ -1227,6 +1227,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 65cb05f97..5f10d38d2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -724,7 +724,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 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", @@ -1154,6 +1154,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 e292ff7b5..a37e79fed 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -719,7 +719,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 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", @@ -1149,6 +1149,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/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)} · ${ 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..e852a1ae8 --- /dev/null +++ b/src/layers/weather/display.test.mjs @@ -0,0 +1,939 @@ +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 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(); + 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..629101693 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,38 @@ 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) { + const text = creditText(frame.creditSnapshot ?? frame.snapshot); + if (frame.credit?.html === text) return false; + frame.credit = new cesium.Credit(text, false); + return true; + } + /** + * Take the credit of a refreshed manifest of the same product. Each frame + * keeps the snapshot it was staged from, whose time and bounds still match, + * and remembers the refreshed one for its credit, including a frame that + * is still loading and takes its credit when installed. + */ + function updateSnapshot(snapshot) { + let changed = false; + for (const frame of new Set([current, incoming, ...retiring])) { + if (!frame || frame.product !== snapshot.product) continue; + frame.creditSnapshot = snapshot; + if (frame.credit && setCredit(frame)) changed = true; + } + if (changed) viewer.scene.requestRender(); + } + function remove(frame) { if (!frame) return; frame.closed = true; @@ -180,6 +213,7 @@ function createGlobeRendering({ return { rehome, cancelPrefetch, + updateSnapshot, async prefetch(snapshot, time, { infrared = 'filtered' } = {}) { let job; try { @@ -220,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(); @@ -234,14 +272,17 @@ function createGlobeRendering({ current.product === snapshot.product && current.kind === kind && current.infrared === infrared - ) + ) { + updateSnapshot(creditSnapshot ?? snapshot); return true; + } lastError = null; const { west, south, east, north } = snapshot.bounds; const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north); const global = snapshot.product === 'clouds'; const frame = { snapshot, + creditSnapshot, time, infrared, mosaic: global @@ -342,14 +383,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 +408,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; @@ -574,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 = () => { @@ -602,8 +646,13 @@ export function createWeatherRendering({ restaging = null; if (!ok) onChange(); }; + // The restaged frame takes the newest refreshed credit; a refresh while + // it loads reaches it through the globe's own updateSnapshot. target - .setFrame(frame.snapshot, frame.time, { infrared: frame.infrared }) + .setFrame(frame.snapshot, frame.time, { + infrared: frame.infrared, + ...(target === globe ? { creditSnapshot: shown?.latest } : {}), + }) .then(settle, () => settle(false)); } function switchHost() { @@ -645,11 +694,31 @@ 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); + }, setHidden(value) { hidden = Boolean(value); if (hidden) restaging = null; @@ -662,6 +731,7 @@ export function createWeatherRendering({ clear() { restaging = null; shown = null; + refreshed = null; hidden = false; shell?.clear(); shell = null; 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 }); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 5698b2115..ac2469e3a 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -574,7 +574,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/rail/RailCardBlocks.vue b/src/ui/rail/RailCardBlocks.vue index 31e9bb8b2..46e734504 100644 --- a/src/ui/rail/RailCardBlocks.vue +++ b/src/ui/rail/RailCardBlocks.vue @@ -31,9 +31,10 @@ const rootTag = (block) => block.type === 'list' ? 'ol' : block.type === 'actions' ? 'footer' : 'div'; const lines = (block) => block.lines || [{ id: block.id, text: block.text }]; -// "960 — 1050+ гПа": the unit stays with the last number. -function 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. +function scaleText(low, high, units, verbatim) { + const unit = verbatim ? units || '' : unitLabel(units); const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`; return unit ? `${range} ${unit}` : range; } @@ -63,7 +64,12 @@ function legendView(legend) { ? `left: ${(legend.zeroIndex / (colors.length - 1)) * 100}%` : undefined, scale: visible - ? scaleText(legend.labels[0], legend.labels.at(-1), legend.units) + ? scaleText( + legend.labels[0], + legend.labels.at(-1), + legend.units, + legend.unitsVerbatim === true, + ) : '', labels: legend?.labels || [], }; diff --git a/src/ui/weatherPanel.js b/src/ui/weatherPanel.js index 554731e54..bb285403e 100644 --- a/src/ui/weatherPanel.js +++ b/src/ui/weatherPanel.js @@ -199,6 +199,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 60aa507ad..d3e3d30cb 100644 --- a/src/ui/weatherPanel.test.mjs +++ b/src/ui/weatherPanel.test.mjs @@ -760,3 +760,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'); + } +});