Merge branch 'main' into fix/remove-snow-preset

This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-29 13:53:35 +00:00
commit 1990228889
23 files changed
+3467 -28

No files matched your search

+31
View File
@@ -36,6 +36,8 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale('uk'), 'uk');
assert.equal(normalizeLocale('uk-UA'), 'uk');
assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('pt-BR'), 'pt');
assert.equal(normalizeLocale('pt_PT'), 'pt');
assert.equal(normalizeLocale('xx'), null);
assert.equal(normalizeLocale('it-IT'), 'it');
assert.equal(normalizeLocale('it_CH'), 'it');
@@ -146,6 +148,20 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
test('tn() picks the Portuguese one, many and other forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('pt');
assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE');
assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE');
assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES');
assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES');
});
test('tn() picks the four Ukrainian plural forms', () => {
const label = (count) =>
tn(
@@ -220,6 +236,21 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
assert.equal(unknown.textContent, 'No such message');
});
test('applyDocumentTranslations reads data-i18n-context for element text', () => {
const option = fakeElement({
text: 'All',
attributes: { 'data-i18n-context': 'radio tag' },
});
const doc = {
querySelectorAll: (selector) =>
selector === '[data-i18n]' ? [option] : [],
};
setLocale('pt');
applyDocumentTranslations(doc);
assert.equal(option.textContent, t('All', { context: 'radio tag' }));
assert.notEqual(option.textContent, t('All'));
});
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
const calls = [];
const location = {
+9 -2
View File
@@ -18,6 +18,7 @@ import {
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import it from './locales/it.json' with { type: 'json' };
import pt from './locales/pt.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' };
import uk from './locales/uk.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' };
@@ -26,6 +27,7 @@ import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({
es,
it,
pt,
ru,
uk,
'zh-CN': zhCN,
@@ -230,7 +232,9 @@ function normalizeMarkupText(text) {
* Markup opt-in: the element's own English text or attribute is the source.
* <span data-i18n>Normal</span>
* <button title="Pin" data-i18n-title>…</button>
* Also data-i18n-aria-label and data-i18n-placeholder.
* Also data-i18n-aria-label and data-i18n-placeholder. data-i18n-context
* gives the element's text a context, as t(text, { context }) does:
* <option data-i18n data-i18n-context="radio tag">All</option>
*/
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
['data-i18n', null],
@@ -255,7 +259,10 @@ export function applyDocumentTranslations(doc) {
? element.getAttribute(attribute)
: normalizeMarkupText(element.textContent);
if (!source) continue;
const text = t(source);
const context = attribute
? null
: element.getAttribute?.('data-i18n-context');
const text = context ? t(source, { context }) : t(source);
if (text === source) continue;
if (attribute) element.setAttribute(attribute, text);
else element.textContent = text;
+3
View File
@@ -12,6 +12,7 @@ export const LOCALES = Object.freeze([
'en',
'es',
'it',
'pt',
'ru',
'uk',
'zh-CN',
@@ -23,6 +24,7 @@ export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
it: 'Italiano',
pt: 'Português (Brasil)',
ru: 'Русский',
uk: 'Українська',
'zh-CN': '中文(简体)',
@@ -34,6 +36,7 @@ const RTL_LOCALES = Object.freeze([]);
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
* @returns {string|null}
+2
View File
@@ -2225,6 +2225,8 @@
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada",
"The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google",
"Weather request timed out": "La petición del clima agotó el tiempo de espera",
"Cyclone request timed out": "La petición de ciclones agotó el tiempo de espera",
"Malformed cyclone snapshot": "Instantánea de ciclones mal formada",
"Malformed weather manifest": "Manifiesto del clima mal formado",
"Weather HTTP {status}": "Clima: HTTP {status}",
"Weather image HTTP {status}": "Imagen del clima: HTTP {status}",
+3 -1
View File
@@ -3004,5 +3004,7 @@
"Begin with a clean globe": "Inizia con un globo pulito",
"Don't show this again": "Non mostrare più",
"ESC to dismiss": "ESC per chiudere",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa."
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout"
}
File diff suppressed because it is too large. Load diff
+2
View File
@@ -2261,6 +2261,8 @@
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан",
"The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API",
"Weather request timed out": "Истекло время ожидания запроса погоды",
"Cyclone request timed out": "Истекло время ожидания запроса циклонов",
"Malformed cyclone snapshot": "Некорректный снимок циклонов",
"Malformed weather manifest": "Некорректный манифест погоды",
"Weather HTTP {status}": "Погода: HTTP {status}",
"Weather image HTTP {status}": "Снимок погоды: HTTP {status}",
+3 -1
View File
@@ -3073,5 +3073,7 @@
"Begin with a clean globe": "Почніть із чистого глобуса",
"Don't show this again": "Більше не показувати",
"ESC to dismiss": "ESC — приховати",
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою."
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Порада: кнопка МІК у доку дає змогу говорити з картою.",
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів"
}
+2
View File
@@ -1203,6 +1203,8 @@
"Some weather tiles unavailable": "部分天气瓦片不可用",
"Malformed weather manifest": "天气清单格式错误",
"Weather request timed out": "天气请求超时",
"Cyclone request timed out": "气旋请求超时",
"Malformed cyclone snapshot": "气旋快照格式错误",
"Temperature field unavailable": "温度场不可用",
"Mean sea level pressure field unavailable": "平均海平面气压场不可用",
"Wind upstream unavailable": "风场上游数据源不可用",
+2
View File
@@ -1198,6 +1198,8 @@
"Some weather tiles unavailable": "部分天氣瓦片不可用",
"Malformed weather manifest": "天氣清單格式錯誤",
"Weather request timed out": "天氣請求超時",
"Cyclone request timed out": "氣旋請求超時",
"Malformed cyclone snapshot": "氣旋快照格式錯誤",
"Temperature field unavailable": "溫度場不可用",
"Mean sea level pressure field unavailable": "平均海平面氣壓場不可用",
"Wind upstream unavailable": "風場上游數據源不可用",
+128
View File
@@ -293,6 +293,134 @@
"ΔN",
"▲ {frp} MW"
],
"pt": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
"ALTITUDE",
"ALTITUDE · FT",
"ANIME",
"AUTO",
"Altitude {altitude}",
"Anime",
"Austin",
"BIKE",
"BRG {value}°",
"BRG —",
"Bloom",
"Blues",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"Country",
"DEST ---°",
"DEST {bearing}",
"Dance",
"Drone",
"Dubai",
"ENE",
"ESE",
"FLIR",
"FLT",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Global",
"Global · 60°S–60°N",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Indie",
"Ironbow",
"Jazz",
"LOCAL",
"Landsat 8/9",
"Landsat 8/9 via HLS",
"Layout",
"Los Angeles",
"MIC",
"MIL",
"MINI",
"MM",
"MONITOR",
"MSG/S",
"Metal",
"NAV",
"NE",
"NNE",
"NOIR",
"NORMAL",
"NVG",
"Noir",
"Normal",
"OCEANIA",
"OK",
"POS: {time}Z",
"PRECIP",
"Paris",
"Pop",
"Punk",
"RANGE",
"REC",
"Reggae",
"Rock",
"S2 GhostMaps",
"SAR",
"SE",
"SIG",
"SNAPSHOT",
"SQUAWK {code} · {status}",
"SSE",
"STATUS",
"STATUS · ",
"Sentinel-2",
"Sentinel-2 via HLS",
"Sonar",
"Soul",
"TAGS · {tags}",
"TEMP",
"Tallinn",
"Techno",
"Trance",
"USB {rate} msg/s",
"V/S {rate}",
"VISUAL",
"VOLUME",
"VS",
"WHOT/BHOT",
"WX",
"dB",
"fire confidence|nominal",
"ft",
"hPa",
"installation class|BASE",
"km",
"km/h",
"kt",
"min",
"mini",
"mph",
"nominal",
"view band|GLOBAL",
"{rate} msg/s",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"ΔN",
"▲ {frp} MW"
],
"ru": [
"AIS: --",
"AIS: {name}",
+60 -11
View File
@@ -1,5 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { t } from '../../i18n/index.js';
import { msg, t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
@@ -8,6 +8,59 @@ import {
isDatasheetFov,
} from './modelSpecs.js';
// The summary line names the CAL badge, the source kind and the view band in
// the page language; the codes underneath stay English.
const CAL_BADGE_LABELS = Object.freeze({
calibrated: msg('CALIBRATED'),
curated: msg('CURATED'),
'raw-prior': msg('RAW PRIOR'),
});
// The same source-kind words as the CCTV panel (src/ui/cctvFrames.js).
const SOURCE_KIND_LABELS = Object.freeze({
snapshot: msg('SNAPSHOT'),
live: msg('LIVE'),
fallback: msg('FALLBACK'),
upstream: msg('UPSTREAM'),
synthetic: msg('SYNTHETIC'),
configured: msg('CONFIGURED'),
seed: msg('SEED'),
image: msg('IMAGE'),
unknown: msg('UNKNOWN'),
});
/** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */
export function summaryCalLabel(badge) {
const key = String(badge || '');
return CAL_BADGE_LABELS[key]
? t(CAL_BADGE_LABELS[key])
: key.replace('-', ' ').toUpperCase();
}
/** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */
export function summarySourceLabel(kind) {
const key = String(kind || 'image').toLowerCase();
return SOURCE_KIND_LABELS[key]
? t(SOURCE_KIND_LABELS[key])
: key.toUpperCase();
}
/** View band id ('street', 'city'…) as the HUD names it. */
export function summaryViewBandLabel(band) {
switch (band) {
case 'street':
return t('STREET', { context: 'view band' });
case 'city':
return t('CITY', { context: 'view band' });
case 'regional':
return t('REGIONAL', { context: 'view band' });
case 'global':
return t('GLOBAL', { context: 'view band' });
default:
return String(band || '').toUpperCase();
}
}
export function createPresentation({
state: layerState,
services,
@@ -72,17 +125,13 @@ export function createPresentation({
: t('ISOLATED VIEW'),
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
t('CAL {value}', {
value: calBadge.replace('-', ' ').toUpperCase(),
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
t('SRC {value}', {
value: summarySourceLabel(
health?.sourceKind || active.camera.feedType || 'image',
),
}),
health?.sourceKind
? t('SRC {value}', {
value: String(health.sourceKind).toUpperCase(),
})
: t('SRC {value}', {
value: String(active.camera.feedType || 'image').toUpperCase(),
}),
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
// Identified hardware: append the model's datasheet specs + attribution.
active.camera.spec
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
+38
View File
@@ -0,0 +1,38 @@
// The CCTV summary line names the CAL badge, the source kind and the view
// band in the page language; unknown codes stay as uppercase codes.
import assert from 'node:assert/strict';
import test from 'node:test';
import { setLocale } from '../../i18n/index.js';
import {
summaryCalLabel,
summarySourceLabel,
summaryViewBandLabel,
} from './presentation.js';
function inLocale(locale, run) {
setLocale(locale);
try {
return run();
} finally {
setLocale('en');
}
}
test('English keeps the summary codes', () => {
assert.equal(summaryCalLabel('raw-prior'), 'RAW PRIOR');
assert.equal(summarySourceLabel('image'), 'IMAGE');
assert.equal(summarySourceLabel('hls'), 'HLS');
assert.equal(summaryViewBandLabel('street'), 'STREET');
assert.equal(summaryViewBandLabel('none'), 'NONE');
});
test('Portuguese summary values are translated', () => {
inLocale('pt', () => {
assert.equal(summaryCalLabel('raw-prior'), 'ESTIMATIVA BRUTA');
assert.equal(summaryCalLabel('calibrated'), 'CALIBRADA');
assert.equal(summarySourceLabel('IMAGE'), 'IMAGEM');
assert.equal(summarySourceLabel('mjpeg'), 'MJPEG');
assert.equal(summaryViewBandLabel('street'), 'RUA');
assert.equal(summaryViewBandLabel('city'), 'CIDADE');
});
});
+6 -2
View File
@@ -1,7 +1,11 @@
import { readResponseJsonCapped } from '../../sources/httpBody.js';
// Shared with the AI tools, so no i18n import: these errors stay English here
// and the cyclones layer translates them where it shows them (translateKnown).
const msg = (text) => text;
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
const malformed = () => new Error('Malformed cyclone snapshot');
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
const text = (value, max = 160) => {
if (
typeof value !== 'string' ||
@@ -215,7 +219,7 @@ export function createCycloneSource({
const abort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', abort, { once: true });
const timer = setTimeout(
() => controller.abort(new Error('Cyclone request timed out')),
() => controller.abort(new Error(msg('Cyclone request timed out'))),
timeoutMs,
);
try {
+3 -3
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'uk', 'en', 'es', 'it', 'zh-CN', 'zh-Hant'],
['ru', 'uk', 'en', 'es', 'it', 'pt', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'it', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
['es', 'en', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'it', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
['en', 'es', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
);
const rows = buildLocaleOptions({
locales: MANY,
+1 -1
View File
@@ -122,7 +122,7 @@
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
<option value="all" data-i18n data-i18n-context="radio tag">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">