mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
commit
5605fdc60a
33 files changed
+1498
-84
No files matched your search
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "動きを減らす設定 · 履歴再生は利用不可",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 морских миль вокруг центра вида; при отдалении карта не становится снимком всего мира. Пустые ячейки — вне охвата выборки или с недостаточным числом наблюдений.",
|
||||||
|
|||||||
@@ -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": "Зменшення руху · відтворення історії недоступне",
|
||||||
|
|||||||
@@ -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": "减弱动效 · 历史回放不可用",
|
||||||
|
|||||||
@@ -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": "減弱動效 · 歷史回放不可用",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)} · ${
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
|
||||||
|
ch
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>`),
|
||||||
|
'<b>"A" & 'B'</b>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockCanvas() {
|
||||||
|
const canvas = { width: 0, height: 0 };
|
||||||
|
canvas.getContext = () => ({
|
||||||
|
setTransform() {},
|
||||||
|
drawImage() {},
|
||||||
|
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
|
||||||
|
putImageData() {},
|
||||||
|
});
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A globe viewer whose imagery collection records providers. */
|
||||||
|
function globeViewer() {
|
||||||
|
const providers = [];
|
||||||
|
const layers = [];
|
||||||
|
const collection = {
|
||||||
|
addImageryProvider(provider) {
|
||||||
|
providers.push(provider);
|
||||||
|
const layer = { imageryProvider: provider, alpha: 1, show: true };
|
||||||
|
layers.push(layer);
|
||||||
|
return layer;
|
||||||
|
},
|
||||||
|
contains: (layer) => layers.includes(layer),
|
||||||
|
remove(layer) {
|
||||||
|
layers.splice(layers.indexOf(layer), 1);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get length() {
|
||||||
|
return layers.length;
|
||||||
|
},
|
||||||
|
get: (index) => layers[index],
|
||||||
|
indexOf: (layer) => layers.indexOf(layer),
|
||||||
|
lower() {},
|
||||||
|
raise() {},
|
||||||
|
raiseToTop() {},
|
||||||
|
isDestroyed: () => false,
|
||||||
|
};
|
||||||
|
const listeners = new Set();
|
||||||
|
const viewer = {
|
||||||
|
imageryLayers: collection,
|
||||||
|
camera: {
|
||||||
|
moveEnd: { addEventListener: () => () => {} },
|
||||||
|
flyTo() {},
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
postRender: {
|
||||||
|
addEventListener(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
primitives: createShellScene().primitives,
|
||||||
|
globe: { tilesLoaded: true },
|
||||||
|
requestRender() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
class Provider {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
this.tilingScheme = new GeographicTilingScheme();
|
||||||
|
this.errorEvent = new Cesium.Event();
|
||||||
|
}
|
||||||
|
requestImage() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cesium = {
|
||||||
|
...createShellCesium(),
|
||||||
|
UrlTemplateImageryProvider: Provider,
|
||||||
|
GeographicTilingScheme,
|
||||||
|
Event: Cesium.Event,
|
||||||
|
Credit: Cesium.Credit,
|
||||||
|
Rectangle: Cesium.Rectangle,
|
||||||
|
};
|
||||||
|
const options = {
|
||||||
|
cesium,
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
headers: new Headers(),
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(1),
|
||||||
|
}),
|
||||||
|
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
|
||||||
|
createCanvas: mockCanvas,
|
||||||
|
};
|
||||||
|
const settle = async () => {
|
||||||
|
await flush();
|
||||||
|
for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn();
|
||||||
|
await flush();
|
||||||
|
};
|
||||||
|
return { viewer, providers, options, settle };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show a frame of `manifest` and return the rendering and its globe. */
|
||||||
|
async function shownFrame(manifest) {
|
||||||
|
const globe = globeViewer();
|
||||||
|
const rendering = createWeatherRendering({
|
||||||
|
viewer: globe.viewer,
|
||||||
|
...globe.options,
|
||||||
|
});
|
||||||
|
const shown = rendering.setFrame(manifest, times[1]);
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(await shown, true);
|
||||||
|
return { ...globe, rendering };
|
||||||
|
}
|
||||||
|
|
||||||
|
const creditOf = (providers) => providers.at(-1).credit.html;
|
||||||
|
|
||||||
|
test('the imagery credit uses the source credit as plain text, else the built-in one', async () => {
|
||||||
|
for (const product of ['radar', 'lightning', 'clouds-regional', 'clouds']) {
|
||||||
|
for (const display of [undefined, { credit: 42 }]) {
|
||||||
|
const h = await shownFrame({
|
||||||
|
...base,
|
||||||
|
product,
|
||||||
|
...(display ? { display } : {}),
|
||||||
|
});
|
||||||
|
assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product);
|
||||||
|
h.rendering.clear();
|
||||||
|
}
|
||||||
|
const h = await shownFrame({
|
||||||
|
...base,
|
||||||
|
product,
|
||||||
|
display: { credit: 'Example <network> & partners' },
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
creditOf(h.providers),
|
||||||
|
'Example <network> & partners',
|
||||||
|
);
|
||||||
|
h.rendering.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refreshed manifest updates the credit of the frame already shown', async () => {
|
||||||
|
for (const product of ['lightning', 'clouds']) {
|
||||||
|
const h = await shownFrame({
|
||||||
|
...base,
|
||||||
|
product,
|
||||||
|
display: { credit: 'First credit' },
|
||||||
|
});
|
||||||
|
assert.equal(creditOf(h.providers), 'First credit');
|
||||||
|
const again = (display) =>
|
||||||
|
h.rendering.setFrame(
|
||||||
|
{ ...base, product, ...(display ? { display } : {}) },
|
||||||
|
times[1],
|
||||||
|
);
|
||||||
|
assert.equal(await again({ credit: 'Second credit' }), true);
|
||||||
|
assert.equal(h.providers.length, 1, 'the frame is not restaged');
|
||||||
|
assert.equal(creditOf(h.providers), 'Second credit');
|
||||||
|
assert.equal(await again(), true);
|
||||||
|
assert.equal(creditOf(h.providers), BUILT_IN[product].credit);
|
||||||
|
h.rendering.updateSnapshot({
|
||||||
|
...base,
|
||||||
|
product,
|
||||||
|
display: { credit: 'Third credit' },
|
||||||
|
});
|
||||||
|
assert.equal(creditOf(h.providers), 'Third credit');
|
||||||
|
// Another product's manifest leaves this frame alone.
|
||||||
|
h.rendering.updateSnapshot({
|
||||||
|
...base,
|
||||||
|
product: 'radar',
|
||||||
|
display: { credit: 'Other' },
|
||||||
|
});
|
||||||
|
assert.equal(creditOf(h.providers), 'Third credit');
|
||||||
|
h.rendering.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refreshed manifest leaves the shown snapshot for a host switch to restage', async () => {
|
||||||
|
const globe = globeViewer();
|
||||||
|
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||||
|
const staged = [];
|
||||||
|
const rendering = createWeatherRendering({
|
||||||
|
viewer: globe.viewer,
|
||||||
|
...globe.options,
|
||||||
|
getHost: () => host,
|
||||||
|
createShell: ({ product }) => ({
|
||||||
|
product,
|
||||||
|
setAlpha() {},
|
||||||
|
setHidden() {},
|
||||||
|
rehome: () => false,
|
||||||
|
cancelPrefetch() {},
|
||||||
|
clear() {},
|
||||||
|
async setFrame(snapshot, time) {
|
||||||
|
staged.push([snapshot, time]);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const first = {
|
||||||
|
...base,
|
||||||
|
product: 'lightning',
|
||||||
|
display: { credit: 'First credit' },
|
||||||
|
};
|
||||||
|
const shown = rendering.setFrame(first, times[0]);
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(await shown, true);
|
||||||
|
const refreshed = {
|
||||||
|
...first,
|
||||||
|
times: [times[1]],
|
||||||
|
bounds: { west: -10, south: 0, east: 10, north: 5 },
|
||||||
|
display: { credit: 'Second credit' },
|
||||||
|
};
|
||||||
|
rendering.updateSnapshot(refreshed);
|
||||||
|
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||||
|
host = { collection: {}, kind: 'tileset' };
|
||||||
|
assert.equal(rendering.rehome(), true);
|
||||||
|
await flush();
|
||||||
|
assert.deepEqual(staged, [[first, times[0]]]);
|
||||||
|
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||||
|
assert.equal(rendering.rehome(), true);
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(globe.providers.length, 2);
|
||||||
|
const { west, south, east, north } = first.bounds;
|
||||||
|
assert.ok(
|
||||||
|
Cesium.Rectangle.equals(
|
||||||
|
globe.providers.at(-1).options.rectangle,
|
||||||
|
Cesium.Rectangle.fromDegrees(west, south, east, north),
|
||||||
|
),
|
||||||
|
'the restaged frame keeps the bounds it was staged from',
|
||||||
|
);
|
||||||
|
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||||
|
rendering.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh during a host-switch restage keeps the newest credit', async () => {
|
||||||
|
const globe = globeViewer();
|
||||||
|
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||||
|
const rendering = createWeatherRendering({
|
||||||
|
viewer: globe.viewer,
|
||||||
|
...globe.options,
|
||||||
|
getHost: () => host,
|
||||||
|
createShell: ({ product }) => ({
|
||||||
|
product,
|
||||||
|
setAlpha() {},
|
||||||
|
setHidden() {},
|
||||||
|
rehome: () => false,
|
||||||
|
cancelPrefetch() {},
|
||||||
|
clear() {},
|
||||||
|
async setFrame() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const first = {
|
||||||
|
...base,
|
||||||
|
product: 'lightning',
|
||||||
|
display: { credit: 'First credit' },
|
||||||
|
};
|
||||||
|
const shown = rendering.setFrame(first, times[0]);
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(await shown, true);
|
||||||
|
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
|
||||||
|
host = { collection: {}, kind: 'tileset' };
|
||||||
|
assert.equal(rendering.rehome(), true);
|
||||||
|
await flush();
|
||||||
|
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||||
|
assert.equal(rendering.rehome(), true);
|
||||||
|
rendering.updateSnapshot({ ...first, display: { credit: 'Third credit' } });
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(globe.providers.length, 2);
|
||||||
|
assert.equal(creditOf(globe.providers), 'Third credit');
|
||||||
|
rendering.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh while a frame loads survives a later host switch', async () => {
|
||||||
|
const globe = globeViewer();
|
||||||
|
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||||
|
const rendering = createWeatherRendering({
|
||||||
|
viewer: globe.viewer,
|
||||||
|
...globe.options,
|
||||||
|
getHost: () => host,
|
||||||
|
createShell: ({ product }) => ({
|
||||||
|
product,
|
||||||
|
setAlpha() {},
|
||||||
|
setHidden() {},
|
||||||
|
rehome: () => false,
|
||||||
|
cancelPrefetch() {},
|
||||||
|
clear() {},
|
||||||
|
async setFrame() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const first = {
|
||||||
|
...base,
|
||||||
|
product: 'lightning',
|
||||||
|
display: { credit: 'First credit' },
|
||||||
|
};
|
||||||
|
const shown = rendering.setFrame(first, times[0]);
|
||||||
|
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(await shown, true);
|
||||||
|
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||||
|
host = { collection: {}, kind: 'tileset' };
|
||||||
|
assert.equal(rendering.rehome(), true);
|
||||||
|
await flush();
|
||||||
|
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||||
|
assert.equal(rendering.rehome(), true);
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(globe.providers.length, 2);
|
||||||
|
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||||
|
rendering.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a refresh before a frame is installed gives it the newest credit', async () => {
|
||||||
|
for (const product of ['lightning', 'clouds']) {
|
||||||
|
const globe = globeViewer();
|
||||||
|
const rendering = createWeatherRendering({
|
||||||
|
viewer: globe.viewer,
|
||||||
|
...globe.options,
|
||||||
|
});
|
||||||
|
const first = { ...base, product, display: { credit: 'First credit' } };
|
||||||
|
const shown = rendering.setFrame(first, times[1]);
|
||||||
|
rendering.updateSnapshot({
|
||||||
|
...first,
|
||||||
|
display: { credit: 'Second credit' },
|
||||||
|
});
|
||||||
|
await globe.settle();
|
||||||
|
assert.equal(await shown, true);
|
||||||
|
assert.equal(creditOf(globe.providers), 'Second credit', product);
|
||||||
|
rendering.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a layer refresh with the same latest frame updates the imagery credit', async (t) => {
|
||||||
|
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||||
|
const globe = globeViewer();
|
||||||
|
let manifest = {
|
||||||
|
...base,
|
||||||
|
product: 'lightning',
|
||||||
|
display: { credit: 'First credit' },
|
||||||
|
};
|
||||||
|
const layer = createWeatherLayer({
|
||||||
|
id: 'weather-lightning',
|
||||||
|
feed: { getSnapshot: async () => manifest },
|
||||||
|
documentRef: quiet(),
|
||||||
|
eventTarget: quiet(),
|
||||||
|
matchMedia: () => quiet(),
|
||||||
|
createRendering: (args) =>
|
||||||
|
createWeatherRendering({ ...args, ...globe.options }),
|
||||||
|
});
|
||||||
|
layer.init(globe.viewer);
|
||||||
|
layer.enable();
|
||||||
|
const first = layer.update();
|
||||||
|
await globe.settle();
|
||||||
|
await first;
|
||||||
|
assert.equal(creditOf(globe.providers), 'First credit');
|
||||||
|
manifest = { ...manifest, display: { credit: 'Second credit' } };
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(globe.providers.length, 1, 'the frame is not restaged');
|
||||||
|
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||||
|
manifest = { ...base, product: 'lightning' };
|
||||||
|
await layer.update();
|
||||||
|
assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit);
|
||||||
|
layer.destroy();
|
||||||
|
});
|
||||||
+85
-49
@@ -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(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 || [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user