Merge current main into S2 GhostMaps and preserve updated import and voice behavior

This commit is contained in:
Mazeyar Moeini Feizabadi committed 2026-10-26 16:25:49 +01:00
commit 4da002bd42
515 files changed
+17681 -2202

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from './index.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
const cases = [
[0, '不到 1 分钟前', '<1m ago'],
[120000, '2 分钟前', '2m ago'],
[7200000, '2 小时前', '2h ago'],
];
for (const [age, zh, en] of cases) {
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
const now = 1800000000000;
context.mock.method(Date, 'now', () => now);
const previousLocale = getLocale();
const layerState = {
_loading: false,
_stale: false,
_keyRequired: false,
_error: null,
_lastUpdate: now - age,
_count: 1,
_cellCount: 0,
_fires: [],
};
const model = createModel({ services: { anchors: {} } });
const { methods } = createQueries({
layerState,
components: { model },
config: {},
});
try {
setLocale('en');
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
setLocale('zh-CN');
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
} finally {
setLocale(previousLocale);
}
});
}
+6
View File
@@ -35,6 +35,12 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null);
// Simplified Chinese by default; Traditional tags wait for zh-TW.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
assert.equal(normalizeLocale('zh-TW'), null);
assert.equal(normalizeLocale('zh-Hant-HK'), null);
assert.equal(normalizeLocale(undefined), null);
});
+3 -2
View File
@@ -18,8 +18,9 @@ import {
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru });
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN });
let currentLocale = DEFAULT_LOCALE;
@@ -209,7 +210,7 @@ export function formatRelativeTime(value, unit = 'second', options) {
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
export function normalizeMarkupText(text) {
function normalizeMarkupText(text) {
return String(text ?? '')
.replace(/\s+/g, ' ')
.trim();
+10 -3
View File
@@ -8,25 +8,32 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru']);
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
ru: 'Русский',
'zh-CN': '简体中文',
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru'.
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN'.
* @returns {string|null}
*/
export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null;
const primary = candidate.trim().toLowerCase().split(/[-_]/)[0];
const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
// Traditional Chinese readers are not given Simplified: no zh-TW yet.
if (primary === 'zh')
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
? null
: 'zh-CN';
return LOCALES.includes(primary) ? primary : null;
}
+17 -2
View File
@@ -1086,7 +1086,6 @@
"FIELD REPORTS · {count}": "INFORMES DE CAMPO · {count}",
"GEOLOCATIONS · {credit}": "GEOLOCALIZACIONES · {credit}",
"↗ OPEN PUBLIC MAP": "↗ ABRIR MAPA PÚBLICO",
"Clouds are preserved from the source imagery.": "Las nubes provienen de las imágenes de origen.",
"Show the Nepal context callout without a border or country fill": "Muestra la etiqueta de contexto de Nepal sin frontera ni relleno del país",
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presenta Nepal, dibuja su frontera y luego revela el incidente del Bhote Koshi",
"CITIES": "CIUDADES",
@@ -1154,6 +1153,7 @@
"OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad",
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.",
"Potential tropical cyclone": "Posible ciclón tropical",
"Post-tropical cyclone": "Ciclón postropical",
"Hurricane": "Huracán",
"Tropical storm": "Tormenta tropical",
"Tropical depression": "Depresión tropical",
@@ -1867,11 +1867,15 @@
"maneuver|Merge": "Incorpórate",
"Merge onto {road}": "Incorpórate a {road}",
"Take the ramp on the {direction}": "Toma la rampa {direction}",
"Take the ramp {direction}": "Toma la rampa {direction}",
"Take the ramp on the {direction} onto {road}": "Toma la rampa {direction} hacia {road}",
"Take the ramp {direction} onto {road}": "Toma la rampa {direction} hacia {road}",
"Take the ramp": "Toma la rampa",
"Take the ramp onto {road}": "Toma la rampa hacia {road}",
"Take the exit on the {direction}": "Toma la salida {direction}",
"Take the exit {direction}": "Toma la salida {direction}",
"Take the exit on the {direction} onto {road}": "Toma la salida {direction} hacia {road}",
"Take the exit {direction} onto {road}": "Toma la salida {direction} hacia {road}",
"Take the exit": "Toma la salida",
"Take the exit onto {road}": "Toma la salida hacia {road}",
"At the roundabout, take the {exit} exit": "En la rotonda, toma la {exit} salida",
@@ -2965,5 +2969,16 @@
"many": "{count} de marcadores",
"other": "{count} marcadores"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe."
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publica estos productos KMZ en GitHub. Las nuevas publicaciones se detectan en segundos y sustituyen automáticamente a la edición anterior. Haz clic en un marcador para ver su informe.",
" Clouds are preserved from the source imagery.": " Las nubes provienen de las imágenes de origen.",
"hemisphere|N": "N",
"hemisphere|S": "S",
"hemisphere|E": "E",
"hemisphere|W": "O",
"OK": "OK",
"{category} ({count})": "{category} ({count})",
"Layers": "Capas",
"Search": "Buscar",
"More": "Más",
"Panels": "Paneles"
}
+17 -2
View File
@@ -1100,7 +1100,6 @@
"FIELD REPORTS · {count}": "СООБЩЕНИЯ С МЕСТА · {count}",
"GEOLOCATIONS · {credit}": "ГЕОЛОКАЦИИ · {credit}",
"↗ OPEN PUBLIC MAP": "↗ ОТКРЫТЬ ПУБЛИЧНУЮ КАРТУ",
"Clouds are preserved from the source imagery.": "Облака оставлены такими, как на исходных снимках.",
"Show the Nepal context callout without a border or country fill": "Показать выноску с контекстом Непала без границы и заливки страны",
"Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Представить Непал, нарисовать его границу, затем показать инцидент на Bhote Koshi",
"CITIES": "ГОРОДА",
@@ -1170,6 +1169,7 @@
"OpenStreetMap · community mapped": "OpenStreetMap · данные сообщества",
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Атлантика и восточная/центральная часть северного Тихого океана; покрытие циклонов не глобальное.",
"Potential tropical cyclone": "Потенциальный тропический циклон",
"Post-tropical cyclone": "Посттропический циклон",
"Hurricane": "Ураган",
"Tropical storm": "Тропический шторм",
"Tropical depression": "Тропическая депрессия",
@@ -1899,11 +1899,15 @@
"maneuver|Merge": "Перестройтесь",
"Merge onto {road}": "Въезжайте на {road}",
"Take the ramp on the {direction}": "Въезжайте на съезд {direction}",
"Take the ramp {direction}": "Въезжайте на съезд {direction}",
"Take the ramp on the {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
"Take the ramp {direction} onto {road}": "Въезжайте по съезду {direction} на {road}",
"Take the ramp": "Въезжайте на съезд",
"Take the ramp onto {road}": "Въезжайте по съезду на {road}",
"Take the exit on the {direction}": "Сверните на съезд {direction}",
"Take the exit {direction}": "Сверните на съезд {direction}",
"Take the exit on the {direction} onto {road}": "Сверните на съезд {direction} на {road}",
"Take the exit {direction} onto {road}": "Сверните на съезд {direction} на {road}",
"Take the exit": "Сверните на съезд",
"Take the exit onto {road}": "Сверните на съезд на {road}",
"At the roundabout, take the {exit} exit": "На кольце сверните на {exit} съезд",
@@ -3034,5 +3038,16 @@
"many": "{count} меток",
"other": "{count} метки"
},
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку."
"S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground публикует эти продукты KMZ на GitHub. Новые файлы обнаруживаются за секунды и автоматически заменяют предыдущий выпуск. Нажмите на метку, чтобы открыть её сводку.",
" Clouds are preserved from the source imagery.": " Облака оставлены такими, как на исходных снимках.",
"hemisphere|N": "С",
"hemisphere|S": "Ю",
"hemisphere|E": "В",
"hemisphere|W": "З",
"OK": "НОРМА",
"{category} ({count})": "{category} ({count})",
"Layers": "Слои",
"Search": "Поиск",
"More": "Ещё",
"Panels": "Панели"
}
File diff suppressed because it is too large. Load diff
+59
View File
@@ -63,6 +63,7 @@
"NVG",
"Noir",
"Normal",
"OK",
"POS: {time}Z",
"Plan",
"Pop",
@@ -100,6 +101,9 @@
"fire confidence|nominal",
"ft",
"hPa",
"hemisphere|E",
"hemisphere|N",
"hemisphere|S",
"installation class|BASE",
"km",
"km/h",
@@ -137,5 +141,60 @@
"S2 · {name}",
"VS",
"WHOT/BHOT"
],
"zh-CN": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"FLIR",
"FOV",
"FOV {value}°",
"FT",
"GEO",
"HDG",
"HDG {value}°",
"House",
"Ironbow",
"KTS",
"Landsat 8/9",
"MM",
"NVG",
"REC",
"SRC {value}",
"Sentinel-2",
"Techno",
"Trance",
"USB {rate} msg/s",
"WHOT/BHOT",
"dB",
"ft",
"hPa",
"hemisphere|E",
"hemisphere|N",
"hemisphere|S",
"hemisphere|W",
"km",
"km/h",
"kt",
"kts",
"mph",
"{city} CCTV",
"{rate} msg/s",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
]
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t } from './index.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js';
import { renderRadioState } from '../ui/radioPresentation.js';
test('Chinese HUD and cockpit units keep their case without spacing coordinates', () => {
try {
setLocale('zh-CN');
assert.equal(hudQuantity(0, 'km'), '0 km');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 km');
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
'扇区 21.33N 77.50W');
setLocale('en');
assert.equal(hudQuantity(0, 'km'), '0KM');
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
assert.equal(formatCockpitDistance(14000), '14\u00a0KM');
} finally {
setLocale('en');
}
});
test('FIRMS stale cache age uses localized durations while English stays compact', () => {
const now = Date.now();
const layerState = { _stale: true };
const components = {
model: createModel({ services: { anchors: {} } }),
};
const { methods } = createQueries({ layerState, components, config: {} });
try {
for (const [hours, zh, en] of [[0.5, '不到 1 小时', '<1h'], [2, '2 小时', '2h'], [72, '3 天', '3d']]) {
layerState._lastUpdate = now - hours * 3600000;
setLocale('zh-CN');
const expected = t('STALE · cached {age}', { age: zh });
assert.equal(methods.getStats().loadingLabel, expected);
assert.equal(methods.getStats().error, expected);
setLocale('en');
assert.equal(methods.getStats().loadingLabel, `STALE · cached ${en}`);
}
} finally {
setLocale('en');
}
});
test('Recent Imagery spaces numeric Chinese ages and retains natural relative dates', () => {
try {
setLocale('zh-CN');
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
assert.equal(READOUT_WORDS.daysAgo(1), '昨天');
setLocale('en');
assert.equal(READOUT_WORDS.daysAgo(9), '9 days ago');
assert.equal(READOUT_WORDS.daysAgo(0), 'today');
} finally {
setLocale('en');
}
});
test('CCTV translates the healthy status and preserves unknown provider codes', () => {
try {
for (const [locale, expected] of [['zh-CN', '正常'], ['ru', 'НОРМА'], ['es', 'OK'], ['en', 'OK']]) {
setLocale(locale);
assert.equal(cctvSourceStatusLabel('ok'), expected);
assert.equal(cctvSourceStatusLabel('CUSTOM'), 'CUSTOM');
}
} finally {
setLocale('en');
}
});
test('radio option text and accessible name use the same localized parentheses', () => {
const originalDocument = globalThis.document;
const element = () => ({
style: {}, dataset: {}, attributes: {},
classList: { contains: () => true, toggle() {} },
setAttribute(name, value) { this.attributes[name] = value; },
replaceChildren(...children) { this.children = children; },
});
globalThis.document = { createElement: element, getElementById: () => null };
try {
for (const [locale, label, expected] of [['zh-CN', '全部', '全部(750)'], ['en', 'All', 'All (750)']]) {
setLocale(locale);
const host = {
_radioPanel: element(), _radioFilter: element(),
_syncContextRadioLauncherState() {},
actions: { getLifecycle: () => null, scheduleLayout() {} },
};
renderRadioState.call(host, {
enabled: true, categories: [{ id: 'all', label, count: 750, color: '' }],
filter: 'all', filteredCount: 750, stationCount: 750, volume: 1,
});
const option = host._radioFilter.children[0];
assert.equal(option.textContent, `● ${expected}`);
assert.equal(option.attributes['aria-label'], expected);
}
} finally {
globalThis.document = originalDocument;
setLocale('en');
}
});