Merge Core main into sameh/core-vue

railCardBlocks.js is the Vue wrapper on this branch, so main's verbatim
legend units move into RailCardBlocks.vue.
This commit is contained in:
Sameh Khamis committed 2026-11-01 17:37:56 -08:00
commit 5605fdc60a
33 files changed
+1498 -84

No files matched your search

+3
View File
@@ -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).
+3
View File
@@ -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).
@@ -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).
+28
View File
@@ -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 flashes nor a live strike counter. Weather imagery orders scalar context below
satellite, radar and lightning consistently across enable and history order. 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 Cyclones combine fixed NHC/CPHC status and summary GIS endpoints through a bounded
same-origin provider. The status endpoint lacks browser CORS; each installation same-origin provider. The status endpoint lacks browser CORS; each installation
fetches keyless public sources at runtime, with no centralized ingestion service. 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`. - 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. - **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. - **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. - 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. - **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. - **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.
+1
View File
@@ -901,6 +901,7 @@
"scripts/qa-weather-swap.mjs", "scripts/qa-weather-swap.mjs",
"scripts/qa-weather-teardown.mjs", "scripts/qa-weather-teardown.mjs",
"src/layers/weather/imageryHost.test.mjs", "src/layers/weather/imageryHost.test.mjs",
"src/layers/weather/display.test.mjs",
"src/sdr/adsbDecoder.test.mjs", "src/sdr/adsbDecoder.test.mjs",
"src/sdr/controller.test.mjs", "src/sdr/controller.test.mjs",
"src/sdr/decoderDiagnostics.test.mjs", "src/sdr/decoderDiagnostics.test.mjs",
+2
View File
@@ -2874,6 +2874,7 @@
"src/layers/weather/index.js", "src/layers/weather/index.js",
"src/layers/weather/clock.js", "src/layers/weather/clock.js",
"src/layers/weather/rendering.js", "src/layers/weather/rendering.js",
"src/layers/weather/display.js",
"src/layers/weather/shellRendering.js", "src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js", "src/layers/weather/infraredImage.js",
@@ -3572,6 +3573,7 @@
"src/layers/weather/index.js", "src/layers/weather/index.js",
"src/layers/weather/clock.js", "src/layers/weather/clock.js",
"src/layers/weather/rendering.js", "src/layers/weather/rendering.js",
"src/layers/weather/display.js",
"src/layers/weather/shellRendering.js", "src/layers/weather/shellRendering.js",
"src/layers/weather/infraredAlpha.js", "src/layers/weather/infraredAlpha.js",
"src/layers/weather/infraredImage.js", "src/layers/weather/infraredImage.js",
+6 -3
View File
@@ -215,9 +215,12 @@ export function formatMeasure(session) {
} }
if (session.shape === 'area') { if (session.shape === 'area') {
const m2 = ringAreaM2(session.vertices); const m2 = ringAreaM2(session.vertices);
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(2)} km²`; // Round before each unit cut, as for a line. 9,999.6 m² was "10000 m²".
if (m2 >= 1e4) return `${(m2 / 1e4).toFixed(1)} ha`; const whole = Math.round(m2);
return `${Math.round(m2)} m²`; 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 ''; return '';
} }
+40
View File
@@ -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.equal(formatMeasure(line), '1.0 km');
assert.match(drawHint(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');
});
+1
View File
@@ -126,6 +126,7 @@ export function createApplicationCatalog({
}), }),
createApplicationGnssIntegrity({ createApplicationGnssIntegrity({
source: sources['gnss-integrity'], source: sources['gnss-integrity'],
windowMs: sources['gnss-integrity'].windowMs,
}), }),
createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }), createApplicationGhostMaps({ source: sources['s2-ghostmaps'] }),
createApplicationAlpr({ surface, source: sources.alpr }), createApplicationAlpr({ surface, source: sources.alpr }),
+39
View File
@@ -193,3 +193,42 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
/Mapillary source/, /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`,
);
});
+6 -1
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import test from 'node:test'; import test from 'node:test';
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import dns from 'node:dns/promises'; import dns from 'node:dns/promises';
import dnsLookup from 'node:dns';
import https from 'node:https'; import https from 'node:https';
import net from 'node:net'; import net from 'node:net';
import { Readable } from 'node:stream'; 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 // 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 // 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) => { test('a real all-addresses connect failure is recognised as address selection', async (t) => {
const probe = net.createServer(); const probe = net.createServer();
try { try {
@@ -272,7 +274,10 @@ test('a real all-addresses connect failure is recognised as address selection',
const v4 = net.createServer(); const v4 = net.createServer();
await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve)); await new Promise((resolve, reject) => v4.once('error', reject).listen(port, '127.0.0.1', resolve));
await new Promise((resolve) => v4.close(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); 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.ok(err, 'a closed port refuses the connection');
assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err)); assert.equal(isAddressSelectionFailure(err), true, describeFetchError(err));
assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/); assert.match(describeFetchError(err), /ECONNREFUSED .*; .*ECONNREFUSED/);
+3 -1
View File
@@ -1453,7 +1453,7 @@
"Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %", "Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %",
"Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %", "Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %",
"GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar", "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 at {time} UTC": "Automatischer neuer Versuch um {time} UTC",
"Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters", "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.", "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", "Contiguous US · gaps ≠ no rain": "USA ohne Alaska/Hawaii · Lücken ≠ kein Regen",
"Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze", "Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze",
"Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³", "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", "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", "North America · infrared imagery": "Nordamerika · Infrarotbilder",
"Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar", "Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar",
+3 -1
View File
@@ -1257,6 +1257,8 @@
"Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia", "Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia",
"Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales", "Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales",
"Color: strikes/km²/min ×10³": "Color: rayos/km²/min ×10³", "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", "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", "North America · infrared imagery": "Norteamérica · imágenes infrarrojas",
"Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible", "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 2–10%": "Índice de baja precisión 2–10%",
"Low-accuracy score > 10%": "Índice de baja precisión > 10%", "Low-accuracy score > 10%": "Índice de baja precisión > 10%",
"GNSS integrity source unavailable": "Fuente de integridad GNSS no disponible", "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 at {time} UTC": "Reintento automático a las {time} UTC",
"Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor", "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.", "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.",
+3 -1
View File
@@ -769,7 +769,7 @@
"Low-accuracy score 2–10%": "Indice de faible précision 2–10 %", "Low-accuracy score 2–10%": "Indice de faible précision 2–10 %",
"Low-accuracy score > 10%": "Indice de faible précision > 10 %", "Low-accuracy score > 10%": "Indice de faible précision > 10 %",
"GNSS integrity source unavailable": "Source d'intégrité GNSS indisponible", "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 at {time} UTC": "Nouvel essai automatique à {time} UTC",
"Retrying automatically after provider cooldown": "Nouvel essai automatique après la pause du fournisseur", "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.", "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", "Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels", "Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³", "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", "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", "North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
"Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible", "Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible",
+3 -1
View File
@@ -769,7 +769,7 @@
"Low-accuracy score 2–10%": "Indice di bassa precisione 2–10%", "Low-accuracy score 2–10%": "Indice di bassa precisione 2–10%",
"Low-accuracy score > 10%": "Indice di bassa precisione > 10%", "Low-accuracy score > 10%": "Indice di bassa precisione > 10%",
"GNSS integrity source unavailable": "Fonte di integrità GNSS non disponibile", "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 at {time} UTC": "Nuovo tentativo automatico alle {time} UTC",
"Retrying automatically after provider cooldown": "Nuovo tentativo automatico dopo la pausa del provider", "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.", "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", "Contiguous US · gaps ≠ no rain": "USA contigui · lacune ≠ assenza di pioggia",
"Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singoli fulmini", "Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singoli fulmini",
"Color: strikes/km²/min ×10³": "Colore: fulmini/km²/min ×10³", "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", "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", "North America · infrared imagery": "Nord America · immagini all'infrarosso",
"Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile", "Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile",
+3 -1
View File
@@ -747,7 +747,7 @@
"Low-accuracy score 2–10%": "低精度スコア 2–10%", "Low-accuracy score 2–10%": "低精度スコア 2–10%",
"Low-accuracy score > 10%": "低精度スコア > 10%", "Low-accuracy score > 10%": "低精度スコア > 10%",
"GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません", "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 at {time} UTC": "{time} UTCに自動で再試行します",
"Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します", "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海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。", "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": "米国本土 · 欠測 ≠ 降水なし", "Contiguous US · gaps ≠ no rain": "米国本土 · 欠測 ≠ 降水なし",
"Americas + Pacific · not individual strikes": "南北アメリカ + 太平洋 · 個々の落雷ではありません", "Americas + Pacific · not individual strikes": "南北アメリカ + 太平洋 · 個々の落雷ではありません",
"Color: strikes/km²/min ×10³": "色:落雷数/km²/分 ×10³", "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時間遅れ", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ",
"North America · infrared imagery": "北米 · 赤外画像", "North America · infrared imagery": "北米 · 赤外画像",
"Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可", "Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可",
+3 -1
View File
@@ -767,7 +767,7 @@
"Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%", "Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%",
"Low-accuracy score > 10%": "Índice de baixa precisão > 10%", "Low-accuracy score > 10%": "Índice de baixa precisão > 10%",
"GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível", "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 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", "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.", "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", "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", "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: 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", "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", "North America · infrared imagery": "América do Norte · imagens infravermelhas",
"Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível", "Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível",
+3 -1
View File
@@ -1275,6 +1275,8 @@
"Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя", "Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя",
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды", "Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды",
"Color: strikes/km²/min ×10³": "Цвет: разрядов/км²/мин ×10³", "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 ч", "60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч",
"North America · infrared imagery": "Северная Америка · ИК-снимки", "North America · infrared imagery": "Северная Америка · ИК-снимки",
"Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно", "Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно",
@@ -2189,7 +2191,7 @@
"Low-accuracy score 2–10%": "Доля неточных 2–10%", "Low-accuracy score 2–10%": "Доля неточных 2–10%",
"Low-accuracy score > 10%": "Доля неточных > 10%", "Low-accuracy score > 10%": "Доля неточных > 10%",
"GNSS integrity source unavailable": "Источник целостности GNSS недоступен", "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 at {time} UTC": "Автоматический повтор в {time} UTC",
"Retrying automatically after provider cooldown": "Автоматический повтор после паузы провайдера", "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 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.", "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 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.",
+3 -1
View File
@@ -779,7 +779,7 @@
"Low-accuracy score 2–10%": "Показник неточності 2–10%", "Low-accuracy score 2–10%": "Показник неточності 2–10%",
"Low-accuracy score > 10%": "Показник неточності > 10%", "Low-accuracy score > 10%": "Показник неточності > 10%",
"GNSS integrity source unavailable": "Джерело даних про цілісність GNSS недоступне", "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 at {time} UTC": "Автоматичний повтор о {time} UTC",
"Retrying automatically after provider cooldown": "Автоматичний повтор після паузи постачальника", "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 морських миль навколо центру карти; у разі віддалення карта не стає знімком усього світу. Порожні комірки — поза охопленням вибірки або з недостатньою кількістю спостережень.", "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 суміжних штатів США · прогалини ≠ немає дощу", "Contiguous US · gaps ≠ no rain": "48 суміжних штатів США · прогалини ≠ немає дощу",
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди", "Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди",
"Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³", "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 год", "60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год",
"North America · infrared imagery": "Північна Америка · ІЧ-знімки", "North America · infrared imagery": "Північна Америка · ІЧ-знімки",
"Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне", "Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне",
+3 -1
View File
@@ -724,7 +724,7 @@
"Low-accuracy score 2–10%": "低精度评分 2–10%", "Low-accuracy score 2–10%": "低精度评分 2–10%",
"Low-accuracy score > 10%": "低精度评分 > 10%", "Low-accuracy score > 10%": "低精度评分 > 10%",
"GNSS integrity source unavailable": "GNSS 完好性数据源不可用", "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 at {time} UTC": "将于 {time} UTC 自动重试",
"Retrying automatically after provider cooldown": "服务商冷却结束后自动重试", "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 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", "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": "美国本土 · 数据缺失 ≠ 无降雨", "Contiguous US · gaps ≠ no rain": "美国本土 · 数据缺失 ≠ 无降雨",
"Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击", "Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击",
"Color: strikes/km²/min ×10³": "颜色:闪击次数/km²/分钟 ×10³", "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 小时", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时",
"North America · infrared imagery": "北美洲 · 红外影像", "North America · infrared imagery": "北美洲 · 红外影像",
"Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用", "Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用",
+3 -1
View File
@@ -719,7 +719,7 @@
"Low-accuracy score 2–10%": "低精度評分 2–10%", "Low-accuracy score 2–10%": "低精度評分 2–10%",
"Low-accuracy score > 10%": "低精度評分 > 10%", "Low-accuracy score > 10%": "低精度評分 > 10%",
"GNSS integrity source unavailable": "GNSS 完好性數據源不可用", "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 at {time} UTC": "將於 {time} UTC 自動重試",
"Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", "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 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", "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": "美國本土 · 數據缺失 ≠ 無降雨", "Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨",
"Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊", "Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊",
"Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³", "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 小時", "60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時",
"North America · infrared imagery": "北美洲 · 紅外影像", "North America · infrared imagery": "北美洲 · 紅外影像",
"Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用", "Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用",
+6
View File
@@ -93,6 +93,7 @@
"Plan", "Plan",
"Pop", "Pop",
"Punk", "Punk",
"RADAR · {units}",
"RADIO", "RADIO",
"REC", "REC",
"REGION", "REGION",
@@ -184,6 +185,7 @@
"CCTV", "CCTV",
"CRS {value}°", "CRS {value}°",
"CRT", "CRT",
"Color: {units}",
"Country", "Country",
"DEST ---°", "DEST ---°",
"DEST {bearing}", "DEST {bearing}",
@@ -235,6 +237,7 @@
"Pop", "Pop",
"Punk", "Punk",
"R&B", "R&B",
"RADAR · {units}",
"RADIO", "RADIO",
"RANGE", "RANGE",
"REC", "REC",
@@ -400,6 +403,7 @@
"Plan", "Plan",
"Pop", "Pop",
"Punk", "Punk",
"RADAR · {units}",
"RADIO", "RADIO",
"REC", "REC",
"Radio", "Radio",
@@ -562,6 +566,7 @@
"PRECIP", "PRECIP",
"Pop", "Pop",
"Punk", "Punk",
"RADAR · {units}",
"RADIO", "RADIO",
"REC", "REC",
"RESET", "RESET",
@@ -765,6 +770,7 @@
"Paris", "Paris",
"Pop", "Pop",
"Punk", "Punk",
"RADAR · {units}",
"RANGE", "RANGE",
"REC", "REC",
"Reggae", "Reggae",
+9 -2
View File
@@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({
viewAnchor = defaultGnssViewAnchor, viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(), now = () => Date.now(),
monotonicNow = () => performance.now(), monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS, windowMs: requestedWindowMs,
} = {}) { } = {}) {
if (typeof source?.getSnapshot !== 'function') if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source'); 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 _viewer = null;
let _primitives = null; let _primitives = null;
let _request = null; let _request = null;
@@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({
chips: [], chips: [],
info: info:
t( 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 (_lastError && _retryAt
? ` · ${translateFeedError(_lastError)} · ${ ? ` · ${translateFeedError(_lastError)} · ${
+153
View File
@@ -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<readonly [string, string]>,
* 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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
ch
],
);
}
+939
View File
@@ -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(`<b>"A" & 'B'</b>`),
'&lt;b&gt;&quot;A&quot; &amp; &#39;B&#39;&lt;/b&gt;',
);
});
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 <network> & partners' },
});
assert.equal(
creditOf(h.providers),
'Example &lt;network&gt; &amp; 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();
});
+85 -49
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js'; import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js'; import { imageryHostStatus } from './imageryHost.js';
import { weatherDisplay, insideExtent } from './display.js';
import { import {
RADAR_MAX_GAP_MS, RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS, REGIONAL_INFRARED_MAX_GAP_MS,
@@ -371,6 +372,8 @@ export function createWeatherLayer({
} }
manifest = snapshot; manifest = snapshot;
error = null; error = null;
// The shown frame may stay; its credit follows the refreshed manifest.
rendering?.updateSnapshot?.(snapshot);
if (clock) { if (clock) {
await clock.refresh(); await clock.refresh();
if ( if (
@@ -467,9 +470,11 @@ export function createWeatherLayer({
manifest?.bounds && manifest?.bounds &&
runNavigation runNavigation
) { ) {
const b = lightning const b =
? { west: 110, south: -25, east: 0, north: 80 } weatherDisplay(manifest).extent ??
: manifest.bounds; (lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds);
runNavigation(() => runNavigation(() =>
viewer.camera.flyTo({ viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees( destination: cesium.Rectangle.fromDegrees(
@@ -526,6 +531,8 @@ export function createWeatherLayer({
getRowControls() { getRowControls() {
const shared = clock?.getState(); const shared = clock?.getState();
const followLatest = isLatest(); const followLatest = isLatest();
// Source-described text is data: shown as is, never translated.
const display = weatherDisplay(manifest);
const missing = const missing =
noFrame && noFrame &&
shared?.mode === 'history' && shared?.mode === 'history' &&
@@ -592,7 +599,9 @@ export function createWeatherLayer({
lon > b.east || lon > b.east ||
lat < b.south || lat < b.south ||
lat > b.north || lat > b.north ||
(lightning && lon > 0 && lon < 110)); (display.extent
? !insideExtent(display.extent, lon, lat)
: lightning && lon > 0 && lon < 110));
let detail = time let detail = time
? t('{state} · {time} · {lag}', { ? t('{state} · {time} · {lag}', {
state: followLatest ? t('Observed') : t('History'), state: followLatest ? t('Observed') : t('History'),
@@ -606,7 +615,9 @@ export function createWeatherLayer({
else if (relation === 'nearest') else if (relation === 'nearest')
detail = t('{detail} · nearest', { detail }); detail = t('{detail} · nearest', { detail });
const infoHeading = radar const infoHeading = radar
? t('RADAR REFLECTIVITY · dBZ') ? display.units !== undefined
? t('RADAR · {units}', { units: display.units })
: t('RADAR REFLECTIVITY · dBZ')
: lightning : lightning
? t('LIGHTNING DENSITY · 15 min accumulation') ? t('LIGHTNING DENSITY · 15 min accumulation')
: product === 'clouds' : product === 'clouds'
@@ -631,13 +642,24 @@ export function createWeatherLayer({
: [t('Observation: unavailable'), loading ? t('loading') : ''] : [t('Observation: unavailable'), loading ? t('loading') : '']
.filter(Boolean) .filter(Boolean)
.join(' · '); .join(' · ');
const infoCoverage = radar const coverageLine =
? t('Contiguous US · gaps ≠ no rain') display.coverage ??
: lightning (radar
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}` ? t('Contiguous US · gaps ≠ no rain')
: product === 'clouds' : lightning
? t('60°S–60°N · typically 2–3 h delayed') ? t('Americas + Pacific · not individual strikes')
: t('North America · infrared imagery'); : 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 const infoMotion = motion?.matches
? clock ? clock
? t('Reduced motion · history playback unavailable') ? t('Reduced motion · history playback unavailable')
@@ -651,13 +673,15 @@ export function createWeatherLayer({
: lightning : lightning
? t('Lightning density · 15 min') ? t('Lightning density · 15 min')
: t('Satellite clouds'), : t('Satellite clouds'),
coverage: radar coverage:
? 'CONUS' display.coverage ??
: lightning (radar
? t('Americas + Pacific') ? 'CONUS'
: product === 'clouds' : lightning
? t('Global · 60°S–60°N') ? t('Americas + Pacific')
: t('North America'), : product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America')),
shownTime: time, shownTime: time,
maxGapMinutes: maxGap() / 60_000, maxGapMinutes: maxGap() / 60_000,
detail, detail,
@@ -674,7 +698,11 @@ export function createWeatherLayer({
: outside : outside
? t('Map center outside coverage') ? t('Map center outside coverage')
: null), : 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: [ chips: [
...(satellite ...(satellite
@@ -723,43 +751,47 @@ export function createWeatherLayer({
})), })),
{ {
id: 'coverage', id: 'coverage',
label: radar label:
? t('View US radar') display.coverage !== undefined || display.extent !== undefined
: lightning ? t('View coverage')
? t('View Americas & Pacific') : radar
: t('View coverage'), ? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
disabled: !manifest || !runNavigation, disabled: !manifest || !runNavigation,
params: { focus: true }, params: { focus: true },
}, },
], ],
legend: legend: stops.map(([label, color]) => ({
radar || lightning label: String(label),
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({ color,
label: String(label), blurb: display.legend
color, ? `${formatScaleLabel(label)} ${display.units}`
blurb: lightning : lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', { ? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label), value: formatScaleLabel(label),
}) })
: t('{value} dBZ radar reflectivity', { : t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label), value: formatScaleLabel(label),
}), }),
})) })),
: [],
info: hostHidden info: hostHidden
? shownHost ? 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}` : ''}`, : `${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 infoTitle:
? t( display.info ??
'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.', (lightning
)
: radar
? t( ? 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( : radar
'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.', ? 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 = [ controls.summary.settings = [
...(satellite ...(satellite
@@ -805,6 +837,7 @@ export function createWeatherLayer({
listener = typeof value === 'function' ? value : null; listener = typeof value === 'function' ? value : null;
}, },
getStats() { getStats() {
const { source } = weatherDisplay(manifest);
return { return {
count: shownTime() ? 1 : 0, count: shownTime() ? 1 : 0,
countLabel: isLatest() ? t('Observed') : t('History'), countLabel: isLatest() ? t('Observed') : t('History'),
@@ -812,7 +845,10 @@ export function createWeatherLayer({
loading, loading,
error: error || rendering?.getDiagnostics().error || null, error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()), 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(), observedAt: shownTime(),
}; };
}, },
+83 -13
View File
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js'; import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js'; import { createRasterTileProvider } from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js'; import { createWeatherShell } from './shellRendering.js';
import { weatherDisplay, escapeCreditText } from './display.js';
import { msg } from '../../i18n/index.js'; import { msg } from '../../i18n/index.js';
import { readResponseBytesCapped } from '../../sources/httpBody.js'; import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { import {
@@ -116,6 +117,38 @@ function createGlobeRendering({
return urls; return urls;
} }
function creditText(snapshot) {
const sourceCredit = weatherDisplay(snapshot).credit;
return sourceCredit !== undefined
? escapeCreditText(sourceCredit)
: snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
}
function setCredit(frame) {
const text = creditText(frame.creditSnapshot ?? frame.snapshot);
if (frame.credit?.html === text) return false;
frame.credit = new cesium.Credit(text, false);
return true;
}
/**
* Take the credit of a refreshed manifest of the same product. Each frame
* keeps the snapshot it was staged from, whose time and bounds still match,
* and remembers the refreshed one for its credit, including a frame that
* is still loading and takes its credit when installed.
*/
function updateSnapshot(snapshot) {
let changed = false;
for (const frame of new Set([current, incoming, ...retiring])) {
if (!frame || frame.product !== snapshot.product) continue;
frame.creditSnapshot = snapshot;
if (frame.credit && setCredit(frame)) changed = true;
}
if (changed) viewer.scene.requestRender();
}
function remove(frame) { function remove(frame) {
if (!frame) return; if (!frame) return;
frame.closed = true; frame.closed = true;
@@ -180,6 +213,7 @@ function createGlobeRendering({
return { return {
rehome, rehome,
cancelPrefetch, cancelPrefetch,
updateSnapshot,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) { async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job; let job;
try { try {
@@ -220,7 +254,11 @@ function createGlobeRendering({
if (job && prefetchJob === job) prefetchJob = null; if (job && prefetchJob === job) prefetchJob = null;
} }
}, },
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) { async setFrame(
snapshot,
time,
{ signal, infrared = 'filtered', creditSnapshot } = {},
) {
signal?.throwIfAborted(); signal?.throwIfAborted();
cancelPrefetch(); cancelPrefetch();
rehome(); rehome();
@@ -234,14 +272,17 @@ function createGlobeRendering({
current.product === snapshot.product && current.product === snapshot.product &&
current.kind === kind && current.kind === kind &&
current.infrared === infrared current.infrared === infrared
) ) {
updateSnapshot(creditSnapshot ?? snapshot);
return true; return true;
}
lastError = null; lastError = null;
const { west, south, east, north } = snapshot.bounds; const { west, south, east, north } = snapshot.bounds;
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north); const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
const global = snapshot.product === 'clouds'; const global = snapshot.product === 'clouds';
const frame = { const frame = {
snapshot, snapshot,
creditSnapshot,
time, time,
infrared, infrared,
mosaic: global mosaic: global
@@ -342,14 +383,8 @@ function createGlobeRendering({
} }
: undefined, : undefined,
); );
const credit = new cesium.Credit( setCredit(frame);
snapshot.product === 'lightning' const credit = frame.credit;
? '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,
);
const provider = global const provider = global
? createRasterTileProvider({ ? createRasterTileProvider({
cesium, cesium,
@@ -373,6 +408,13 @@ function createGlobeRendering({
enablePickFeatures: false, enablePickFeatures: false,
credit, 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); const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => { provider.requestImage = (x, y, level, request) => {
if (frame.closed) return undefined; if (frame.closed) return undefined;
@@ -574,6 +616,8 @@ export function createWeatherRendering({
let hidden = false; let hidden = false;
let shown = null; let shown = null;
let restaging = 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. // A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => { const wanted = () => {
@@ -602,8 +646,13 @@ export function createWeatherRendering({
restaging = null; restaging = null;
if (!ok) onChange(); 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 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)); .then(settle, () => settle(false));
} }
function switchHost() { function switchHost() {
@@ -645,11 +694,31 @@ export function createWeatherRendering({
switchHost(); switchHost();
restaging = null; restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe; const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const before = refreshed;
const ok = await target.setFrame(snapshot, time, options); const ok = await target.setFrame(snapshot, time, options);
if (ok) if (ok) {
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' }; // 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; 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) { setHidden(value) {
hidden = Boolean(value); hidden = Boolean(value);
if (hidden) restaging = null; if (hidden) restaging = null;
@@ -662,6 +731,7 @@ export function createWeatherRendering({
clear() { clear() {
restaging = null; restaging = null;
shown = null; shown = null;
refreshed = null;
hidden = false; hidden = false;
shell?.clear(); shell?.clear();
shell = null; shell = null;
+2 -1
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp'; import sharp from 'sharp';
import { import {
CUBE_FACES, 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) { for (const face of CUBE_FACES) {
const { data, info } = await sharp( 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() .raw()
.toBuffer({ resolveWithObject: true }); .toBuffer({ resolveWithObject: true });
+5 -1
View File
@@ -574,7 +574,11 @@ export class LayerPanel {
const stats = layer.stats || {}; const stats = layer.stats || {};
const feedState = layerFeedState(stats); const feedState = layerFeedState(stats);
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]); 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 = const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled'); layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') { if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
+18
View File
@@ -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');
}
});
+10 -4
View File
@@ -31,9 +31,10 @@ const rootTag = (block) =>
block.type === 'list' ? 'ol' : block.type === 'actions' ? 'footer' : 'div'; block.type === 'list' ? 'ol' : block.type === 'actions' ? 'footer' : 'div';
const lines = (block) => block.lines || [{ id: block.id, text: block.text }]; const lines = (block) => block.lines || [{ id: block.id, text: block.text }];
// "960 — 1050+ гПа": the unit stays with the last number. // "960 — 1050+ гПа": the unit stays with the last number. Units a source
function scaleText(low, high, units) { // supplied as data are shown as given.
const unit = unitLabel(units); function scaleText(low, high, units, verbatim) {
const unit = verbatim ? units || '' : unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`; const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range} ${unit}` : range; return unit ? `${range} ${unit}` : range;
} }
@@ -63,7 +64,12 @@ function legendView(legend) {
? `left: ${(legend.zeroIndex / (colors.length - 1)) * 100}%` ? `left: ${(legend.zeroIndex / (colors.length - 1)) * 100}%`
: undefined, : undefined,
scale: visible 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 || [], labels: legend?.labels || [],
}; };
+1
View File
@@ -199,6 +199,7 @@ export function createWeatherPanel({
colors: legend.map(({ color }) => color), colors: legend.map(({ color }) => color),
labels: legend.map(({ label }) => label), labels: legend.map(({ label }) => label),
units: categorical ? '' : summary.units, units: categorical ? '' : summary.units,
...(summary.unitsVerbatim === true ? { unitsVerbatim: true } : {}),
zeroIndex: legend.findIndex(({ label }) => label === '0'), zeroIndex: legend.findIndex(({ label }) => label === '0'),
}, },
}; };
+24
View File
@@ -760,3 +760,27 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir
view.destroy(); view.destroy();
layer.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');
}
});