mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Let a weather source describe its own products
A weather source can describe its own products (credit, units, coverage, legend) through an optional `display` object in each product manifest: source, credit, units, coverage, info, legend and extent. The weather layer uses each valid field in the row summary, legend, info text, coverage navigation, outside-coverage status, layer stats and imagery credit, and keeps today's built-in value for any field that is absent or invalid. Units and legend describe one scale, so they apply only as a valid pair; otherwise both keep their built-in values, and the radar info heading names the units in use. Text is single-line and length-capped. It rejects control characters (U+0000-U+001F, U+007F-U+009F), line and paragraph separators, and invisible or format characters: soft hyphen (U+00AD), combining grapheme joiner (U+034F), Arabic letter mark (U+061C), Mongolian vowel separator (U+180E), zero-width and direction marks (U+200B-U+200F), bidi embedding and override (U+202A-U+202E), word joiner, invisible operators and bidi isolates (U+2060-U+2069), variation selectors (U+FE00-U+FE0F), the byte-order mark (U+FEFF) and tag characters (U+E0000-U+E007F). Legends need 2-16 stops whose labels are plain decimal numbers or short text and whose colours are #rrggbb. A number label must read back exactly as the legend shows it: within the safe integer range, with no leading zeros and no digits lost to rounding. Extents must be finite, in range and non-empty. Supplied source and units text is data: the layer marks it so the panels show it untranslated, while built-in values are translated as before. The imagery credit is escaped as plain text and follows a refreshed manifest even when the shown frame stays the same. A refresh changes only the credit: the shown frame keeps the snapshot it was staged from, so a later host switch restages it with its own time and bounds and the refreshed credit. Manifests without `display`, including the bundled nowCOAST ones, render exactly as before.
This commit is contained in:
1 parent
3cd7d0fe15
commit
039b0828b7
23 files changed
+1243
-63
No files matched your search
@@ -1759,6 +1759,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "USA ohne Alaska/Hawaii · Lücken ≠ kein Regen",
|
||||
"Americas + Pacific · not individual strikes": "Amerika + Pazifik · keine Einzelblitze",
|
||||
"Color: strikes/km²/min ×10³": "Farbe: Blitze/km²/min ×10³",
|
||||
"Color: {units}": "Farbe: {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · meist 2–3 h verzögert",
|
||||
"North America · infrared imagery": "Nordamerika · Infrarotbilder",
|
||||
"Reduced motion · history playback unavailable": "Reduzierte Bewegung · Verlaufswiedergabe nicht verfügbar",
|
||||
|
||||
@@ -1260,6 +1260,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "EE. UU. contiguos · huecos ≠ sin lluvia",
|
||||
"Americas + Pacific · not individual strikes": "América + Pacífico · no son rayos individuales",
|
||||
"Color: strikes/km²/min ×10³": "Color: rayos/km²/min ×10³",
|
||||
"Color: {units}": "Color: {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente con 2–3 h de retraso",
|
||||
"North America · infrared imagery": "Norteamérica · imágenes infrarrojas",
|
||||
"Reduced motion · history playback unavailable": "Movimiento reducido · reproducción del historial no disponible",
|
||||
|
||||
@@ -1214,6 +1214,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "États-Unis contigus · lacunes ≠ absence de pluie",
|
||||
"Americas + Pacific · not individual strikes": "Amériques + Pacifique · pas des impacts individuels",
|
||||
"Color: strikes/km²/min ×10³": "Couleur : impacts/km²/min ×10³",
|
||||
"Color: {units}": "Couleur : {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · retard habituel de 2 à 3 h",
|
||||
"North America · infrared imagery": "Amérique du Nord · imagerie infrarouge",
|
||||
"Reduced motion · history playback unavailable": "Animations réduites · lecture de l'historique indisponible",
|
||||
|
||||
@@ -1214,6 +1214,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "USA contigui · lacune ≠ assenza di pioggia",
|
||||
"Americas + Pacific · not individual strikes": "Americhe + Pacifico · non singoli fulmini",
|
||||
"Color: strikes/km²/min ×10³": "Colore: fulmini/km²/min ×10³",
|
||||
"Color: {units}": "Colore: {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · di solito 2–3 h di ritardo",
|
||||
"North America · infrared imagery": "Nord America · immagini all'infrarosso",
|
||||
"Reduced motion · history playback unavailable": "Movimento ridotto · riproduzione dello storico non disponibile",
|
||||
|
||||
@@ -1182,6 +1182,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "米国本土 · 欠測 ≠ 降水なし",
|
||||
"Americas + Pacific · not individual strikes": "南北アメリカ + 太平洋 · 個々の落雷ではありません",
|
||||
"Color: strikes/km²/min ×10³": "色:落雷数/km²/分 ×10³",
|
||||
"Color: {units}": "色:{units}",
|
||||
"RADAR · {units}": "レーダー · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常2–3時間遅れ",
|
||||
"North America · infrared imagery": "北米 · 赤外画像",
|
||||
"Reduced motion · history playback unavailable": "動きを減らす設定 · 履歴再生は利用不可",
|
||||
|
||||
@@ -1212,6 +1212,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "EUA contíguos · lacunas ≠ sem chuva",
|
||||
"Americas + Pacific · not individual strikes": "Américas + Pacífico · não são raios individuais",
|
||||
"Color: strikes/km²/min ×10³": "Cor: raios/km²/min ×10³",
|
||||
"Color: {units}": "Cor: {units}",
|
||||
"RADAR · {units}": "RADAR · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · normalmente com 2–3 h de atraso",
|
||||
"North America · infrared imagery": "América do Norte · imagens infravermelhas",
|
||||
"Reduced motion · history playback unavailable": "Movimento reduzido · reprodução do histórico indisponível",
|
||||
|
||||
@@ -1278,6 +1278,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "Континентальные США · пробелы ≠ нет дождя",
|
||||
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не отдельные разряды",
|
||||
"Color: strikes/km²/min ×10³": "Цвет: разрядов/км²/мин ×10³",
|
||||
"Color: {units}": "Цвет: {units}",
|
||||
"RADAR · {units}": "РАДАР · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60° ю. ш.–60° с. ш. · обычно задержка 2–3 ч",
|
||||
"North America · infrared imagery": "Северная Америка · ИК-снимки",
|
||||
"Reduced motion · history playback unavailable": "Уменьшение движения · воспроизведение истории недоступно",
|
||||
|
||||
@@ -1229,6 +1229,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "48 суміжних штатів США · прогалини ≠ немає дощу",
|
||||
"Americas + Pacific · not individual strikes": "Америка + Тихий океан · не окремі розряди",
|
||||
"Color: strikes/km²/min ×10³": "Колір: розрядів/км²/хв ×10³",
|
||||
"Color: {units}": "Колір: {units}",
|
||||
"RADAR · {units}": "РАДАР · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60° пд. ш.–60° пн. ш. · зазвичай затримка 2–3 год",
|
||||
"North America · infrared imagery": "Північна Америка · ІЧ-знімки",
|
||||
"Reduced motion · history playback unavailable": "Зменшення руху · відтворення історії недоступне",
|
||||
|
||||
@@ -1156,6 +1156,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "美国本土 · 数据缺失 ≠ 无降雨",
|
||||
"Americas + Pacific · not individual strikes": "美洲与太平洋 · 非单次闪击",
|
||||
"Color: strikes/km²/min ×10³": "颜色:闪击次数/km²/分钟 ×10³",
|
||||
"Color: {units}": "颜色:{units}",
|
||||
"RADAR · {units}": "雷达 · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延迟 2–3 小时",
|
||||
"North America · infrared imagery": "北美洲 · 红外影像",
|
||||
"Reduced motion · history playback unavailable": "减弱动效 · 历史回放不可用",
|
||||
|
||||
@@ -1151,6 +1151,8 @@
|
||||
"Contiguous US · gaps ≠ no rain": "美國本土 · 數據缺失 ≠ 無降雨",
|
||||
"Americas + Pacific · not individual strikes": "美洲與太平洋 · 非單次閃擊",
|
||||
"Color: strikes/km²/min ×10³": "顏色:閃擊次數/km²/分鐘 ×10³",
|
||||
"Color: {units}": "顏色:{units}",
|
||||
"RADAR · {units}": "雷達 · {units}",
|
||||
"60°S–60°N · typically 2–3 h delayed": "60°S–60°N · 通常延遲 2–3 小時",
|
||||
"North America · infrared imagery": "北美洲 · 紅外影像",
|
||||
"Reduced motion · history playback unavailable": "減弱動效 · 歷史回放不可用",
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REGION",
|
||||
@@ -184,6 +185,7 @@
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Color: {units}",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
@@ -235,6 +237,7 @@
|
||||
"Pop",
|
||||
"Punk",
|
||||
"R&B",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"RANGE",
|
||||
"REC",
|
||||
@@ -400,6 +403,7 @@
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"Radio",
|
||||
@@ -562,6 +566,7 @@
|
||||
"PRECIP",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"RESET",
|
||||
@@ -765,6 +770,7 @@
|
||||
"Paris",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADAR · {units}",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Reggae",
|
||||
|
||||
@@ -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,839 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { GeographicTilingScheme } from 'cesium';
|
||||
import { createWeatherLayer } from './index.js';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
|
||||
import { createShellCesium, createShellScene } from './shellFixture.mjs';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z'];
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
times,
|
||||
latest: times[1],
|
||||
bounds: { west: -180, south: -60, east: 180, north: 80 },
|
||||
};
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
const quiet = () => ({
|
||||
matches: false,
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
|
||||
/**
|
||||
* A layer whose renderer shows every requested frame at once. `center` is the
|
||||
* map centre as [lon, lat]; `manifests` maps a product to its manifest, and
|
||||
* may be changed between updates.
|
||||
*/
|
||||
async function shownLayer(id, manifest, params, view = {}) {
|
||||
let time = null;
|
||||
const flights = [];
|
||||
const snapshots = [];
|
||||
const manifests = { [manifest.product]: manifest };
|
||||
const layer = createWeatherLayer({
|
||||
id,
|
||||
feed: { getSnapshot: async ({ product }) => manifests[product] },
|
||||
documentRef: quiet(),
|
||||
eventTarget: quiet(),
|
||||
matchMedia: () => quiet(),
|
||||
createRendering: () => ({
|
||||
rehome() {},
|
||||
setAlpha() {},
|
||||
setHidden() {},
|
||||
updateSnapshot(snapshot) {
|
||||
snapshots.push(snapshot);
|
||||
},
|
||||
async setFrame(_snapshot, selected) {
|
||||
time = selected;
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
time = null;
|
||||
},
|
||||
getDiagnostics: () => ({ time, loading: false, error: null }),
|
||||
}),
|
||||
});
|
||||
layer.init({
|
||||
camera: {
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
flyTo: ({ destination }) => flights.push(destination),
|
||||
pickEllipsoid: () =>
|
||||
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined,
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
});
|
||||
if (params) layer.setParams(params);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
await flush();
|
||||
layer.attachShellServices({ runNavigation: (navigate) => navigate() });
|
||||
return { layer, flights, manifests, snapshots };
|
||||
}
|
||||
|
||||
const PRODUCTS = [
|
||||
['weather-radar', 'radar', undefined],
|
||||
['weather-lightning', 'lightning', undefined],
|
||||
['weather-satellite', 'clouds-regional', undefined],
|
||||
['weather-satellite', 'clouds', { product: 'clouds' }],
|
||||
];
|
||||
const INVALID = {
|
||||
source: 42,
|
||||
credit: '',
|
||||
units: 'x'.repeat(41),
|
||||
coverage: 'line one\nline two',
|
||||
info: ['not', 'text'],
|
||||
legend: [
|
||||
{ label: '1', color: '#ffffff' },
|
||||
{ label: '2', color: 'red' },
|
||||
],
|
||||
extent: { west: 0, south: 10, east: 20, north: 5 },
|
||||
};
|
||||
|
||||
const SATELLITE_TITLE =
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.';
|
||||
const SATELLITE_CREDIT =
|
||||
'NOAA nowCOAST · NESDIS GOES / global satellite partners';
|
||||
// Today's built-in description of each product, written out rather than
|
||||
// derived from the code under test.
|
||||
const BUILT_IN = {
|
||||
radar: {
|
||||
units: 'dBZ',
|
||||
coverage: 'CONUS',
|
||||
legend: [
|
||||
['10', '#00ecec'],
|
||||
['20', '#0000f6'],
|
||||
['30', '#00c800'],
|
||||
['40', '#ffff00'],
|
||||
['50', '#ff9000'],
|
||||
['60', '#dc0000'],
|
||||
['70', '#ff00ff'],
|
||||
['80', '#05ede0'],
|
||||
].map(([label, color]) => ({
|
||||
label,
|
||||
color,
|
||||
blurb: `${label} dBZ radar reflectivity`,
|
||||
})),
|
||||
heading: 'RADAR REFLECTIVITY · dBZ',
|
||||
infoCoverage: 'Contiguous US · gaps ≠ no rain',
|
||||
infoTitle:
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
chip: 'View US radar',
|
||||
focus: [-180, -60, 180, 80],
|
||||
credit: 'NOAA nowCOAST · NWS/OAR MRMS',
|
||||
},
|
||||
lightning: {
|
||||
units: 'strikes/km²/min ×10³',
|
||||
coverage: 'Americas + Pacific',
|
||||
legend: [
|
||||
['0.1', '#FFFFCC'],
|
||||
['1', '#FFA400'],
|
||||
['5', '#FF4500'],
|
||||
['10', '#FF0000'],
|
||||
['50', '#FF00FF'],
|
||||
['100', '#4000C0'],
|
||||
['200', '#00C7FF'],
|
||||
['300+', '#00FF00'],
|
||||
].map(([label, color]) => ({
|
||||
label,
|
||||
color,
|
||||
blurb: `${label} strikes/km²/min ×10³ (15-minute density)`,
|
||||
})),
|
||||
heading: 'LIGHTNING DENSITY · 15 min accumulation',
|
||||
infoCoverage:
|
||||
'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³',
|
||||
infoTitle:
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
chip: 'View Americas & Pacific',
|
||||
focus: [110, -25, 0, 80],
|
||||
credit: 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360',
|
||||
},
|
||||
'clouds-regional': {
|
||||
units: '',
|
||||
coverage: 'North America',
|
||||
legend: [],
|
||||
heading: 'GOES INFRARED · ~5 min',
|
||||
infoCoverage: 'North America · infrared imagery',
|
||||
infoTitle: SATELLITE_TITLE,
|
||||
chip: 'View coverage',
|
||||
focus: [-180, -60, 180, 80],
|
||||
credit: SATELLITE_CREDIT,
|
||||
},
|
||||
clouds: {
|
||||
units: '',
|
||||
coverage: 'Global · 60°S–60°N',
|
||||
legend: [],
|
||||
heading: 'GLOBAL INFRARED · hourly',
|
||||
infoCoverage: '60°S–60°N · typically 2–3 h delayed',
|
||||
infoTitle: SATELLITE_TITLE,
|
||||
chip: 'View coverage',
|
||||
focus: [-180, -60, 180, 80],
|
||||
credit: SATELLITE_CREDIT,
|
||||
},
|
||||
};
|
||||
|
||||
/** Assert the built-in description of a product, field by field. */
|
||||
function assertBuiltIn(layer, product) {
|
||||
const expected = BUILT_IN[product];
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.summary.units, expected.units);
|
||||
assert.equal(controls.summary.unitsVerbatim, undefined);
|
||||
assert.equal(controls.summary.coverage, expected.coverage);
|
||||
assert.deepEqual(controls.legend, expected.legend);
|
||||
assert.ok(
|
||||
controls.info.startsWith(`${expected.heading}\n`),
|
||||
`${product} heading: ${controls.info}`,
|
||||
);
|
||||
assert.ok(
|
||||
controls.info.endsWith(`\n${expected.infoCoverage}`),
|
||||
`${product} coverage: ${controls.info}`,
|
||||
);
|
||||
assert.equal(controls.infoTitle, expected.infoTitle);
|
||||
assert.equal(
|
||||
controls.chips.find(({ id }) => id === 'coverage').label,
|
||||
expected.chip,
|
||||
);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.source, 'NOAA nowCOAST');
|
||||
assert.equal(stats.sourceVerbatim, undefined);
|
||||
}
|
||||
|
||||
for (const [id, product, params] of PRODUCTS) {
|
||||
test(`${product}: a manifest without display metadata keeps today's description`, async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const { layer, flights } = await shownLayer(
|
||||
id,
|
||||
{ ...base, product },
|
||||
params,
|
||||
);
|
||||
assertBuiltIn(layer, product);
|
||||
layer.setParams({ focus: true });
|
||||
assert.deepEqual(
|
||||
flights[0],
|
||||
Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus),
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, product, params] of PRODUCTS) {
|
||||
test(`${product}: empty or wholly invalid display metadata changes nothing`, async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const plain = await shownLayer(id, { ...base, product }, params);
|
||||
const empty = await shownLayer(
|
||||
id,
|
||||
{ ...base, product, display: {} },
|
||||
params,
|
||||
);
|
||||
const invalid = await shownLayer(
|
||||
id,
|
||||
{ ...base, product, display: INVALID },
|
||||
params,
|
||||
);
|
||||
const expected = plain.layer.getRowControls();
|
||||
assert.deepEqual(empty.layer.getRowControls(), expected);
|
||||
assert.deepEqual(invalid.layer.getRowControls(), expected);
|
||||
assertBuiltIn(invalid.layer, product);
|
||||
for (const h of [plain, invalid]) h.layer.setParams({ focus: true });
|
||||
assert.deepEqual(invalid.flights, plain.flights);
|
||||
for (const h of [plain, empty, invalid]) h.layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
const EXAMPLE = {
|
||||
source: 'Example network',
|
||||
credit: 'Example network · flash density',
|
||||
units: 'flashes/km²/min',
|
||||
coverage: '52°S–52°N',
|
||||
info: 'Flash density from an example network. Not a safety warning.',
|
||||
legend: [
|
||||
{ label: 0.5, color: '#112233' },
|
||||
{ label: '2', color: '#445566' },
|
||||
{ label: '8+', color: '#AABBCC' },
|
||||
],
|
||||
extent: { west: -180, south: -52, east: 180, north: 52 },
|
||||
};
|
||||
|
||||
test('valid display metadata describes the product in the row, legend and stats', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const { layer, flights } = await shownLayer('weather-lightning', {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: EXAMPLE,
|
||||
});
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.summary.units, 'flashes/km²/min');
|
||||
assert.equal(controls.summary.unitsVerbatim, true);
|
||||
assert.equal(controls.summary.coverage, '52°S–52°N');
|
||||
assert.deepEqual(controls.legend, [
|
||||
{ label: '0.5', color: '#112233', blurb: '0.5 flashes/km²/min' },
|
||||
{ label: '2', color: '#445566', blurb: '2 flashes/km²/min' },
|
||||
{ label: '8+', color: '#AABBCC', blurb: '8+ flashes/km²/min' },
|
||||
]);
|
||||
assert.match(controls.info, /^LIGHTNING DENSITY · 15 min accumulation\n/);
|
||||
assert.match(controls.info, /\n52°S–52°N\nColor: flashes\/km²\/min$/);
|
||||
assert.equal(controls.infoTitle, EXAMPLE.info);
|
||||
assert.equal(
|
||||
controls.chips.find(({ id }) => id === 'coverage').label,
|
||||
'View coverage',
|
||||
);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.source, 'Example network');
|
||||
assert.equal(stats.sourceVerbatim, true);
|
||||
layer.setParams({ focus: true });
|
||||
assert.deepEqual(
|
||||
flights[0],
|
||||
Cesium.Rectangle.fromDegrees(-180, -52, 180, 52),
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('units and legend apply only as a valid pair', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const legend = [
|
||||
{ label: 1, color: '#000000' },
|
||||
{ label: 10, color: '#ffffff' },
|
||||
];
|
||||
for (const [id, product] of [
|
||||
['weather-lightning', 'lightning'],
|
||||
['weather-radar', 'radar'],
|
||||
])
|
||||
for (const display of [
|
||||
{ units: 'mm/h' },
|
||||
{ legend },
|
||||
{ units: 'mm/h', legend: [legend[0]] },
|
||||
{ units: 'x'.repeat(41), legend },
|
||||
]) {
|
||||
const { layer } = await shownLayer(id, { ...base, product, display });
|
||||
assertBuiltIn(layer, product);
|
||||
layer.destroy();
|
||||
}
|
||||
const radar = await shownLayer('weather-radar', {
|
||||
...base,
|
||||
product: 'radar',
|
||||
display: { coverage: 'Europe', units: 'mm/h', legend },
|
||||
});
|
||||
const controls = radar.layer.getRowControls();
|
||||
assert.equal(controls.summary.coverage, 'Europe');
|
||||
assert.equal(controls.summary.units, 'mm/h');
|
||||
assert.equal(controls.summary.unitsVerbatim, true);
|
||||
assert.deepEqual(controls.legend, [
|
||||
{ label: '1', color: '#000000', blurb: '1 mm/h' },
|
||||
{ label: '10', color: '#ffffff', blurb: '10 mm/h' },
|
||||
]);
|
||||
assert.match(controls.info, /^RADAR · mm\/h\n/);
|
||||
assert.match(controls.info, /\nEurope$/);
|
||||
radar.layer.destroy();
|
||||
const clouds = await shownLayer('weather-satellite', {
|
||||
...base,
|
||||
product: 'clouds-regional',
|
||||
display: {
|
||||
units: 'K',
|
||||
legend: [
|
||||
{ label: 'warm', color: '#000000' },
|
||||
{ label: 'cold', color: '#ffffff' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(clouds.layer.getRowControls().summary.units, 'K');
|
||||
assert.deepEqual(clouds.layer.getRowControls().legend, [
|
||||
{ label: 'warm', color: '#000000', blurb: 'warm K' },
|
||||
{ label: 'cold', color: '#ffffff', blurb: 'cold K' },
|
||||
]);
|
||||
clouds.layer.destroy();
|
||||
});
|
||||
|
||||
test('other display fields fall back on their own', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const { layer } = await shownLayer('weather-lightning', {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: { ...INVALID, coverage: 'Example coverage', source: 'Example' },
|
||||
});
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.summary.coverage, 'Example coverage');
|
||||
assert.equal(controls.summary.units, BUILT_IN.lightning.units);
|
||||
assert.deepEqual(controls.legend, BUILT_IN.lightning.legend);
|
||||
assert.equal(controls.infoTitle, BUILT_IN.lightning.infoTitle);
|
||||
assert.equal(layer.getStats().source, 'Example');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('the map centre is checked against the source extent when one is given', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const OUTSIDE = 'Map center is outside source coverage';
|
||||
const outside = (layer) => layer.getRowControls().info.includes(OUTSIDE);
|
||||
const at = async (id, product, display, center) => {
|
||||
const h = await shownLayer(
|
||||
id,
|
||||
{ ...base, product, ...(display ? { display } : {}) },
|
||||
undefined,
|
||||
{ center },
|
||||
);
|
||||
const result = outside(h.layer);
|
||||
if (result)
|
||||
assert.equal(
|
||||
h.layer.getRowControls().summary.status,
|
||||
'Map center outside coverage',
|
||||
);
|
||||
h.layer.destroy();
|
||||
return result;
|
||||
};
|
||||
// Without an extent: the manifest bounds, plus lightning's built-in gap.
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', null, [50, 10]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', null, [-100, 10]),
|
||||
false,
|
||||
);
|
||||
assert.equal(await at('weather-radar', 'radar', null, [50, 10]), false);
|
||||
assert.equal(await at('weather-radar', 'radar', null, [50, -70]), true);
|
||||
// An extent replaces the built-in gap.
|
||||
const global = { extent: { west: -180, south: -52, east: 180, north: 52 } };
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', global, [50, 10]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', global, [50, 60]),
|
||||
true,
|
||||
);
|
||||
const europe = { extent: { west: -10, south: 35, east: 30, north: 70 } };
|
||||
assert.equal(await at('weather-radar', 'radar', europe, [10, 50]), false);
|
||||
assert.equal(await at('weather-radar', 'radar', europe, [-50, 50]), true);
|
||||
assert.equal(await at('weather-radar', 'radar', europe, [10, 30]), true);
|
||||
// West greater than east crosses the antimeridian.
|
||||
const pacific = { extent: { west: 150, south: -30, east: -120, north: 60 } };
|
||||
for (const [center, expected] of [
|
||||
[[170, 0], false],
|
||||
[[-150, 0], false],
|
||||
[[0, 0], true],
|
||||
[[-100, 0], true],
|
||||
[[170, 70], true],
|
||||
])
|
||||
assert.equal(
|
||||
await at('weather-radar', 'radar', pacific, center),
|
||||
expected,
|
||||
`centre ${center}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching to a product without display metadata restores its built-ins', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const h = await shownLayer('weather-satellite', {
|
||||
...base,
|
||||
product: 'clouds-regional',
|
||||
display: EXAMPLE,
|
||||
});
|
||||
h.manifests.clouds = { ...base, product: 'clouds' };
|
||||
assert.equal(h.layer.getStats().source, 'Example network');
|
||||
h.layer.setParams({ product: 'clouds' });
|
||||
await flush();
|
||||
assertBuiltIn(h.layer, 'clouds');
|
||||
h.layer.setParams({ product: 'clouds-regional' });
|
||||
await flush();
|
||||
assert.equal(h.layer.getStats().source, 'Example network');
|
||||
assert.equal(h.layer.getRowControls().summary.coverage, '52°S–52°N');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('a refresh that drops display metadata restores the built-ins', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const h = await shownLayer('weather-lightning', {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: EXAMPLE,
|
||||
});
|
||||
assert.equal(h.layer.getStats().source, 'Example network');
|
||||
const plain = { ...base, product: 'lightning' };
|
||||
h.manifests.lightning = plain;
|
||||
await h.layer.update();
|
||||
assertBuiltIn(h.layer, 'lightning');
|
||||
assert.equal(h.snapshots.at(-1), plain, 'the renderer sees the refresh');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('display validation bounds text, legend shape, colours and extents', () => {
|
||||
const read = (display) => weatherDisplay({ display });
|
||||
const scale = (legend) => read({ units: 'u', legend }).legend;
|
||||
assert.deepEqual(read(undefined), {});
|
||||
assert.deepEqual(read(['credit']), {});
|
||||
assert.deepEqual(read({ coverage: ' Europe ' }), { coverage: 'Europe' });
|
||||
assert.deepEqual(read({ coverage: ' ' }), {});
|
||||
assert.deepEqual(read({ credit: 'a
b' }), {});
|
||||
assert.deepEqual(read({ credit: 'x'.repeat(200) }).credit.length, 200);
|
||||
assert.deepEqual(read({ credit: 'x'.repeat(201) }), {});
|
||||
assert.deepEqual(read({ info: 'x'.repeat(601) }), {});
|
||||
// Invisible and direction-changing characters are rejected in every field.
|
||||
for (const code of [
|
||||
0x00ad, 0x034f, 0x061c, 0x180e, 0x200b, 0x200d, 0x200e, 0x200f, 0x202a,
|
||||
0x202d, 0x202e, 0x2060, 0x2066, 0x2069, 0xfe00, 0xfe0f, 0xfeff, 0xe0000,
|
||||
0xe0001, 0xe0041, 0xe007f,
|
||||
]) {
|
||||
const ch = String.fromCodePoint(code);
|
||||
const value = `a${ch}b`;
|
||||
for (const key of ['source', 'credit', 'units', 'coverage', 'info'])
|
||||
assert.equal(
|
||||
read({ [key]: value, legend: [{ label: 1, color: '#000000' }] })[key],
|
||||
undefined,
|
||||
`${key} with U+${ch.codePointAt(0).toString(16)}`,
|
||||
);
|
||||
assert.equal(
|
||||
scale([
|
||||
{ label: '0', color: '#000000' },
|
||||
{ label: value, color: '#000000' },
|
||||
]),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
assert.equal(read({ coverage: 'a b · c–d' }).coverage, 'a b · c–d');
|
||||
const stop = (label, color = '#000000') => ({ label, color });
|
||||
assert.equal(scale([stop('1')]), undefined);
|
||||
assert.equal(
|
||||
scale(Array.from({ length: 17 }, (_, i) => stop(String(i)))),
|
||||
undefined,
|
||||
);
|
||||
for (const bad of [
|
||||
stop(Number.NaN),
|
||||
stop(Infinity),
|
||||
stop(0.1 + 0.2),
|
||||
stop(1e21),
|
||||
stop(1e-7),
|
||||
stop(''),
|
||||
stop('x'.repeat(17)),
|
||||
stop('1', '#fff'),
|
||||
stop('1', 'rgb(0,0,0)'),
|
||||
stop('1', '#00000g'),
|
||||
null,
|
||||
'1',
|
||||
])
|
||||
assert.equal(scale([stop('0'), bad]), undefined);
|
||||
// A number label must read back exactly as the legend formats it.
|
||||
for (const bad of [
|
||||
'9999999999999999',
|
||||
'9007199254740992',
|
||||
'9007199254740992+',
|
||||
'007',
|
||||
'00.5',
|
||||
9007199254740992,
|
||||
])
|
||||
assert.equal(scale([stop('0'), stop(bad)]), undefined, String(bad));
|
||||
assert.deepEqual(
|
||||
scale([
|
||||
stop('9007199254740991'),
|
||||
stop(-900719925474099),
|
||||
stop('-0.50'),
|
||||
stop('1.25+'),
|
||||
stop('123456.789012345'),
|
||||
]),
|
||||
[
|
||||
['9007199254740991', '#000000'],
|
||||
['-900719925474099', '#000000'],
|
||||
['-0.50', '#000000'],
|
||||
['1.25+', '#000000'],
|
||||
['123456.789012345', '#000000'],
|
||||
],
|
||||
);
|
||||
for (const label of ['9007199254740991', '-0.50', '1.25+', '0.001'])
|
||||
assert.equal(formatScaleLabel(label), label);
|
||||
assert.equal(formatScaleLabel('9999999999999999'), '10000000000000000');
|
||||
assert.deepEqual(scale([stop(0), stop(-2.5), stop('1', '#ABCDEF')]), [
|
||||
['0', '#000000'],
|
||||
['-2.5', '#000000'],
|
||||
['1', '#ABCDEF'],
|
||||
]);
|
||||
// A legend needs its units and units need their legend.
|
||||
assert.deepEqual(read({ units: 'u' }), {});
|
||||
assert.deepEqual(read({ legend: [stop(0), stop(1)] }), {});
|
||||
for (const bad of [
|
||||
{ west: -10, south: 0, east: 10 },
|
||||
{ west: -10, south: 0, east: 10, north: '5' },
|
||||
{ west: -181, south: 0, east: 10, north: 5 },
|
||||
{ west: -10, south: -91, east: 10, north: 5 },
|
||||
{ west: -10, south: 5, east: 10, north: 5 },
|
||||
{ west: -10, south: 6, east: 10, north: 5 },
|
||||
{ west: 10, south: 0, east: 10, north: 5 },
|
||||
{ west: 180, south: 0, east: -180, north: 5 },
|
||||
{ west: -180, south: 0, east: -180, north: 5 },
|
||||
])
|
||||
assert.equal(read({ extent: bad }).extent, undefined, JSON.stringify(bad));
|
||||
for (const good of [
|
||||
{ west: -180, south: -90, east: 180, north: 90 },
|
||||
{ west: 170, south: 0, east: -180, north: 5 },
|
||||
])
|
||||
assert.deepEqual(read({ extent: good }).extent, good);
|
||||
const wrap = read({ extent: { west: 110, south: -25, east: 0, north: 80 } });
|
||||
assert.equal(insideExtent(wrap.extent, 150, 10), true);
|
||||
assert.equal(insideExtent(wrap.extent, -100, 10), true);
|
||||
assert.equal(insideExtent(wrap.extent, 50, 10), false);
|
||||
assert.equal(insideExtent(wrap.extent, -100, 81), false);
|
||||
assert.equal(
|
||||
escapeCreditText(`<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 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 { createWeatherRendering } from './rendering.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { weatherDisplay, insideExtent } from './display.js';
|
||||
import {
|
||||
RADAR_MAX_GAP_MS,
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
@@ -371,6 +372,8 @@ export function createWeatherLayer({
|
||||
}
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
// The shown frame may stay; its credit follows the refreshed manifest.
|
||||
rendering?.updateSnapshot?.(snapshot);
|
||||
if (clock) {
|
||||
await clock.refresh();
|
||||
if (
|
||||
@@ -467,9 +470,11 @@ export function createWeatherLayer({
|
||||
manifest?.bounds &&
|
||||
runNavigation
|
||||
) {
|
||||
const b = lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds;
|
||||
const b =
|
||||
weatherDisplay(manifest).extent ??
|
||||
(lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds);
|
||||
runNavigation(() =>
|
||||
viewer.camera.flyTo({
|
||||
destination: cesium.Rectangle.fromDegrees(
|
||||
@@ -526,6 +531,8 @@ export function createWeatherLayer({
|
||||
getRowControls() {
|
||||
const shared = clock?.getState();
|
||||
const followLatest = isLatest();
|
||||
// Source-described text is data: shown as is, never translated.
|
||||
const display = weatherDisplay(manifest);
|
||||
const missing =
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
@@ -592,7 +599,9 @@ export function createWeatherLayer({
|
||||
lon > b.east ||
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
(display.extent
|
||||
? !insideExtent(display.extent, lon, lat)
|
||||
: lightning && lon > 0 && lon < 110));
|
||||
let detail = time
|
||||
? t('{state} · {time} · {lag}', {
|
||||
state: followLatest ? t('Observed') : t('History'),
|
||||
@@ -606,7 +615,9 @@ export function createWeatherLayer({
|
||||
else if (relation === 'nearest')
|
||||
detail = t('{detail} · nearest', { detail });
|
||||
const infoHeading = radar
|
||||
? t('RADAR REFLECTIVITY · dBZ')
|
||||
? display.units !== undefined
|
||||
? t('RADAR · {units}', { units: display.units })
|
||||
: t('RADAR REFLECTIVITY · dBZ')
|
||||
: lightning
|
||||
? t('LIGHTNING DENSITY · 15 min accumulation')
|
||||
: product === 'clouds'
|
||||
@@ -631,13 +642,24 @@ export function createWeatherLayer({
|
||||
: [t('Observation: unavailable'), loading ? t('loading') : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const infoCoverage = radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery');
|
||||
const coverageLine =
|
||||
display.coverage ??
|
||||
(radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? t('Americas + Pacific · not individual strikes')
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery'));
|
||||
const infoCoverage = lightning
|
||||
? `${coverageLine}\n${
|
||||
display.units === undefined
|
||||
? t('Color: strikes/km²/min ×10³')
|
||||
: t('Color: {units}', { units: display.units })
|
||||
}`
|
||||
: coverageLine;
|
||||
const stops =
|
||||
display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []);
|
||||
const infoMotion = motion?.matches
|
||||
? clock
|
||||
? t('Reduced motion · history playback unavailable')
|
||||
@@ -651,13 +673,15 @@ export function createWeatherLayer({
|
||||
: lightning
|
||||
? t('Lightning density · 15 min')
|
||||
: t('Satellite clouds'),
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America'),
|
||||
coverage:
|
||||
display.coverage ??
|
||||
(radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America')),
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail,
|
||||
@@ -674,7 +698,11 @@ export function createWeatherLayer({
|
||||
: outside
|
||||
? t('Map center outside coverage')
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
|
||||
units:
|
||||
display.units ??
|
||||
(radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : ''),
|
||||
// Source units are data: the panel shows them untranslated.
|
||||
...(display.units !== undefined ? { unitsVerbatim: true } : {}),
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
@@ -723,43 +751,47 @@ export function createWeatherLayer({
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
label:
|
||||
display.coverage !== undefined || display.extent !== undefined
|
||||
? t('View coverage')
|
||||
: radar
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
],
|
||||
legend:
|
||||
radar || lightning
|
||||
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
}))
|
||||
: [],
|
||||
legend: stops.map(([label, color]) => ({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: display.legend
|
||||
? `${formatScaleLabel(label)} ${display.units}`
|
||||
: lightning
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
})),
|
||||
info: hostHidden
|
||||
? shownHost
|
||||
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
|
||||
infoTitle: lightning
|
||||
? t(
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: radar
|
||||
infoTitle:
|
||||
display.info ??
|
||||
(lightning
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
),
|
||||
: radar
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
)),
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
@@ -805,6 +837,7 @@ export function createWeatherLayer({
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
getStats() {
|
||||
const { source } = weatherDisplay(manifest);
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? t('Observed') : t('History'),
|
||||
@@ -812,7 +845,10 @@ export function createWeatherLayer({
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
stale: Boolean(manifest?.stale || observationDelayed()),
|
||||
source: 'NOAA nowCOAST',
|
||||
...(source === undefined
|
||||
? { source: 'NOAA nowCOAST' }
|
||||
: // Source text is data: the panel shows it untranslated.
|
||||
{ source, sourceVerbatim: true }),
|
||||
observedAt: shownTime(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createWeatherShell } from './shellRendering.js';
|
||||
import { weatherDisplay, escapeCreditText } from './display.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import {
|
||||
@@ -116,6 +117,35 @@ function createGlobeRendering({
|
||||
return urls;
|
||||
}
|
||||
|
||||
function creditText(snapshot) {
|
||||
const sourceCredit = weatherDisplay(snapshot).credit;
|
||||
return sourceCredit !== undefined
|
||||
? escapeCreditText(sourceCredit)
|
||||
: snapshot.product === 'lightning'
|
||||
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
|
||||
: snapshot.product === 'radar'
|
||||
? 'NOAA nowCOAST · NWS/OAR MRMS'
|
||||
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
|
||||
}
|
||||
function setCredit(frame, snapshot = frame.snapshot) {
|
||||
const text = creditText(snapshot);
|
||||
if (frame.credit?.html === text) return false;
|
||||
frame.credit = new cesium.Credit(text, false);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Take the credit of a refreshed manifest of the same product. Each frame
|
||||
* keeps the snapshot it was staged from, whose time and bounds still match.
|
||||
*/
|
||||
function updateSnapshot(snapshot) {
|
||||
let changed = false;
|
||||
for (const frame of new Set([current, incoming, ...retiring])) {
|
||||
if (!frame || frame.product !== snapshot.product) continue;
|
||||
if (frame.credit && setCredit(frame, snapshot)) changed = true;
|
||||
}
|
||||
if (changed) viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function remove(frame) {
|
||||
if (!frame) return;
|
||||
frame.closed = true;
|
||||
@@ -180,6 +210,7 @@ function createGlobeRendering({
|
||||
return {
|
||||
rehome,
|
||||
cancelPrefetch,
|
||||
updateSnapshot,
|
||||
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
|
||||
let job;
|
||||
try {
|
||||
@@ -234,8 +265,10 @@ function createGlobeRendering({
|
||||
current.product === snapshot.product &&
|
||||
current.kind === kind &&
|
||||
current.infrared === infrared
|
||||
)
|
||||
) {
|
||||
updateSnapshot(snapshot);
|
||||
return true;
|
||||
}
|
||||
lastError = null;
|
||||
const { west, south, east, north } = snapshot.bounds;
|
||||
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
@@ -342,14 +375,8 @@ function createGlobeRendering({
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const credit = new cesium.Credit(
|
||||
snapshot.product === 'lightning'
|
||||
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
|
||||
: snapshot.product === 'radar'
|
||||
? 'NOAA nowCOAST · NWS/OAR MRMS'
|
||||
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
|
||||
false,
|
||||
);
|
||||
setCredit(frame);
|
||||
const credit = frame.credit;
|
||||
const provider = global
|
||||
? createRasterTileProvider({
|
||||
cesium,
|
||||
@@ -373,6 +400,13 @@ function createGlobeRendering({
|
||||
enablePickFeatures: false,
|
||||
credit,
|
||||
});
|
||||
// Cesium reads a provider's credit every frame; a refreshed manifest
|
||||
// can replace it without restaging the imagery.
|
||||
Object.defineProperty(provider, 'credit', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => frame.credit,
|
||||
});
|
||||
const requestImage = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (frame.closed) return undefined;
|
||||
@@ -601,6 +635,8 @@ export function createWeatherRendering({
|
||||
if (restaging !== frame) return;
|
||||
restaging = null;
|
||||
if (!ok) onChange();
|
||||
else if (frame.latest && target === globe)
|
||||
globe.updateSnapshot(frame.latest);
|
||||
};
|
||||
target
|
||||
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
|
||||
@@ -650,6 +686,13 @@ export function createWeatherRendering({
|
||||
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
|
||||
return ok;
|
||||
},
|
||||
// The shown frame keeps its staged snapshot for a restage on another
|
||||
// host; only the refreshed credit is remembered and applied.
|
||||
updateSnapshot(snapshot) {
|
||||
if (shown?.snapshot.product === snapshot.product)
|
||||
shown = { ...shown, latest: snapshot };
|
||||
globe.updateSnapshot(snapshot);
|
||||
},
|
||||
setHidden(value) {
|
||||
hidden = Boolean(value);
|
||||
if (hidden) restaging = null;
|
||||
|
||||
@@ -635,7 +635,11 @@ export class LayerPanel {
|
||||
const stats = layer.stats || {};
|
||||
const feedState = layerFeedState(stats);
|
||||
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
|
||||
const source = sourceLabel(stats.source || layer.source);
|
||||
// A layer marks source text that is data rather than a catalog name.
|
||||
const source =
|
||||
stats.sourceVerbatim === true && stats.source
|
||||
? stats.source
|
||||
: sourceLabel(stats.source || layer.source);
|
||||
const lifecycleState =
|
||||
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
|
||||
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -16,9 +16,10 @@ const order = (parent, nodes) =>
|
||||
parent.insertBefore(node, parent.children[i] || null);
|
||||
});
|
||||
|
||||
// "960 — 1050+ гПа": the unit stays with the last number.
|
||||
const scaleText = (low, high, units) => {
|
||||
const unit = unitLabel(units);
|
||||
// "960 — 1050+ гПа": the unit stays with the last number. Units a source
|
||||
// supplied as data are shown as given.
|
||||
const scaleText = (low, high, units, verbatim) => {
|
||||
const unit = verbatim ? units || '' : unitLabel(units);
|
||||
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
|
||||
return unit ? `${range}\u00a0${unit}` : range;
|
||||
};
|
||||
@@ -309,7 +310,12 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
|
||||
block.scale,
|
||||
'textContent',
|
||||
visible
|
||||
? scaleText(legend.labels[0], legend.labels.at(-1), legend.units)
|
||||
? scaleText(
|
||||
legend.labels[0],
|
||||
legend.labels.at(-1),
|
||||
legend.units,
|
||||
legend.unitsVerbatim === true,
|
||||
)
|
||||
: '',
|
||||
);
|
||||
} else if (props.type === 'actions') {
|
||||
|
||||
@@ -215,6 +215,7 @@ export function createWeatherPanel({
|
||||
colors: legend.map(({ color }) => color),
|
||||
labels: legend.map(({ label }) => label),
|
||||
units: categorical ? '' : summary.units,
|
||||
...(summary.unitsVerbatim === true ? { unitsVerbatim: true } : {}),
|
||||
zeroIndex: legend.findIndex(({ label }) => label === '0'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -752,3 +752,27 @@ test('air-quality readout renders its band legend, Canada coverage and worst-fir
|
||||
view.destroy();
|
||||
layer.destroy();
|
||||
});
|
||||
test('source-supplied legend units are shown as given; catalog units are translated', () => {
|
||||
setLocale('ru');
|
||||
try {
|
||||
const f = fixture();
|
||||
const view = createWeatherPanel(f);
|
||||
const row = (summary) => ({
|
||||
...radar,
|
||||
summary: { ...radar.summary, units: 'km/h', ...summary },
|
||||
legend: [
|
||||
{ label: '1', color: '#000000' },
|
||||
{ label: '2', color: '#ffffff' },
|
||||
],
|
||||
});
|
||||
const scale = () =>
|
||||
f.find((n) => n.className === 'rail-card-scale').textContent;
|
||||
view.update([row()]);
|
||||
assert.equal(scale(), '1 — 2 км/ч');
|
||||
view.update([row({ unitsVerbatim: true })]);
|
||||
assert.equal(scale(), '1 — 2 km/h');
|
||||
view.destroy();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user