From fd78bc38845c7fbc6fc8bd82c6eac1e6b0048155 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 21 Oct 2026 13:36:38 +0400 Subject: [PATCH] fix(zh-CN): localize readouts and keep short labels intact Preserve Chinese unit casing and spacing in the HUD and cockpit, localize FIRMS cache ages, CCTV health and radio counts, and keep imagery resolution and route modes readable. Add regression coverage for localized output and unchanged English. Co-Authored-By: Codex (GPT-6) --- src/hudLocalityFormat.js | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/same-as-english.json | 1 + src/i18n/zh-CN-display.test.mjs | 106 ++++++++++++++++++++++++++++ src/layers/firms/model.js | 1 + src/layers/firms/queries.js | 3 +- src/layers/recentImagery/display.js | 12 +++- src/ui/cctvFrames.js | 3 +- src/ui/cockpitContext.js | 4 +- src/ui/cockpitPresentation.js | 7 +- src/ui/radioPresentation.js | 11 +-- src/ui/styles/cjk.css | 25 +++++++ 14 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 src/i18n/zh-CN-display.test.mjs diff --git a/src/hudLocalityFormat.js b/src/hudLocalityFormat.js index 84278564b..b508f8d59 100644 --- a/src/hudLocalityFormat.js +++ b/src/hudLocalityFormat.js @@ -15,7 +15,9 @@ export function hudQuantity(value, unit, digits = 0) { minimumFractionDigits: digits, maximumFractionDigits: digits, }); - return `${number}${unitLabel(unit).toUpperCase()}`; + const label = unitLabel(unit); + if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; + return `${number}${label.toUpperCase()}`; } /** One hemisphere-tagged coordinate, e.g. `21.33N` / `21,33С`. */ diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index cbba42e0e..c26430fe7 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2941,5 +2941,7 @@ "hemisphere|N": "N", "hemisphere|S": "S", "hemisphere|E": "E", - "hemisphere|W": "O" + "hemisphere|W": "O", + "OK": "OK", + "{category} ({count})": "{category} ({count})" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index dcd6ef20b..dadea1a42 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3008,5 +3008,7 @@ "hemisphere|N": "С", "hemisphere|S": "Ю", "hemisphere|E": "В", - "hemisphere|W": "З" + "hemisphere|W": "З", + "OK": "НОРМА", + "{category} ({count})": "{category} ({count})" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8b5240e12..6b815f545 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2807,5 +2807,7 @@ "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Gemini 被打断的输入超出了缓冲区或等待上限。请重新启动语音。", "Gemini Live connection is expiring. Start voice again for a new conversation.": "Gemini Live 连接即将到期。请重新启动语音以开始新对话。", "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini 语音会话有时长限制。会话到期后,请重新启动语音以开始新对话。", - "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)" + "Show the street-level view full screen (Esc shrinks it)": "全屏显示街景视图(按 Esc 缩小)", + "OK": "正常", + "{category} ({count})": "{category}({count})" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 821b154b9..c331cd070 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -63,6 +63,7 @@ "NVG", "Noir", "Normal", + "OK", "POS: {time}Z", "Plan", "Pop", diff --git a/src/i18n/zh-CN-display.test.mjs b/src/i18n/zh-CN-display.test.mjs new file mode 100644 index 000000000..b9e55058d --- /dev/null +++ b/src/i18n/zh-CN-display.test.mjs @@ -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'); + } +}); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 3a1df327b..226c32286 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -591,6 +591,7 @@ export function createModel({ formatLatLon, formatFrp, formatAge, + formatAgeLabel, formatAgoMinutes, confidenceBucket, }; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index a7d22589e..c4a793f9d 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -64,7 +64,8 @@ export function createQueries({ const staleText = layerState._lastUpdate ? t('STALE · cached {age}', { age: - components.model.formatAge(now - layerState._lastUpdate) || '<1h', + components.model.formatAgeLabel(now - layerState._lastUpdate) || + t('<1h', { context: 'age' }), }) : t('STALE'); let loadingLabel = ''; diff --git a/src/layers/recentImagery/display.js b/src/layers/recentImagery/display.js index 5b2b5ce39..5c18d8b3f 100644 --- a/src/layers/recentImagery/display.js +++ b/src/layers/recentImagery/display.js @@ -3,7 +3,12 @@ * day's readout. The model (`model.js`) stays English and i18n-free because AI * tool services import it outside the browser; this module is page-only. */ -import { formatDate, formatRelativeTime, t } from '../../i18n/index.js'; +import { + formatDate, + formatRelativeTime, + getLocale, + t, +} from '../../i18n/index.js'; import { formatQuantity } from '../../ui/unitLabels.js'; import { dayTime } from './model.js'; @@ -26,7 +31,10 @@ export const displayMetres = (value) => formatQuantity(value, 'm'); /** The readout's words in the page's language; the model keeps them English. */ export const READOUT_WORDS = Object.freeze({ date: (day) => displayDay(day, LONG_DAY), - daysAgo: (delta) => formatRelativeTime(delta > 0 ? -delta : 0, 'day'), + daysAgo: (delta) => { + const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); + return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; + }, // "Sentinel-2" never breaks at its hyphen: a word joiner follows it // (JetBrains Mono has no U+2011 non-breaking hyphen). sensor: (sensor) => diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index 7976d46c8..22f4f0dce 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -19,6 +19,7 @@ const SOURCE_KIND_LABELS = Object.freeze({ }); const SOURCE_STATUS_LABELS = Object.freeze({ + ok: msg('OK'), degraded: msg('DEGRADED'), error: msg('ERROR'), unknown: msg('UNKNOWN'), @@ -51,7 +52,7 @@ export function cctvSourceKindLabel(kind) { : key.toUpperCase(); } -/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */ +/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */ export function cctvSourceStatusLabel(status) { const key = String(status || 'unknown').toLowerCase(); return SOURCE_STATUS_LABELS[key] diff --git a/src/ui/cockpitContext.js b/src/ui/cockpitContext.js index 57db349ee..852cc32a8 100644 --- a/src/ui/cockpitContext.js +++ b/src/ui/cockpitContext.js @@ -1,6 +1,6 @@ /** Contact readouts and nearby-signal presentation for the Cockpit controller. */ import * as Cesium from 'cesium'; -import { formatNumber, t, tn } from '../i18n/index.js'; +import { formatNumber, getLocale, t, tn } from '../i18n/index.js'; import { unitLabel } from './unitLabels.js'; import { bearingBetweenCoordinates, @@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({ window: ({ label, radiusKm }) => t('{label} · {radius} AIR/SEA WINDOW', { label, - radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`, + radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`, }), installations: ({ scope, coverage }) => t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index f8930d4e0..8285b74b3 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -1,5 +1,5 @@ /** Pure formatting and fixed presentation constants used by Cockpit components. */ -import { formatNumber, t, msg } from '../i18n/index.js'; +import { formatNumber, getLocale, t, msg } from '../i18n/index.js'; import { formatQuantity } from './unitLabels.js'; export const COCKPIT_HEADING_SLEW_DPS = 28; @@ -145,10 +145,11 @@ export function formatCockpitCoordinate(value, positive, negative) { */ export function formatCockpitDistance(distanceM) { const digits = distanceM < 10000 ? 1 : 0; - return formatQuantity(distanceM / 1000, 'km', { + const distance = formatQuantity(distanceM / 1000, 'km', { minimumFractionDigits: digits, maximumFractionDigits: digits, - }).toUpperCase(); + }); + return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); } export function formatCockpitWindDirection(value) { diff --git a/src/ui/radioPresentation.js b/src/ui/radioPresentation.js index 94431fdec..54ee379e8 100644 --- a/src/ui/radioPresentation.js +++ b/src/ui/radioPresentation.js @@ -114,13 +114,14 @@ export function renderRadioState(state) { ...state.categories.map((category) => { const option = document.createElement('option'); option.value = category.id; - option.textContent = `● ${category.label} (${category.count})`; + const label = t('{category} ({count})', { + category: category.label, + count: category.count, + }); + option.textContent = `● ${label}`; option.dataset.radioColor = category.color; option.style.color = category.color; - option.setAttribute( - 'aria-label', - `${category.label} (${category.count})`, - ); + option.setAttribute('aria-label', label); return option; }), ); diff --git a/src/ui/styles/cjk.css b/src/ui/styles/cjk.css index c44431520..9ecfa0ce3 100644 --- a/src/ui/styles/cjk.css +++ b/src/ui/styles/cjk.css @@ -27,3 +27,28 @@ html:lang(zh) .panel-layer-toggle { html:lang(zh) .pp-mode-btn { white-space: nowrap; } + +/* Keep unit symbols in their catalog case, including the cockpit readouts. */ +html:lang(zh) #intel-hud *, +html:lang(zh) #cockpit-hud * { + text-transform: none; +} + +/* Move short travel modes (步行 / 骑行) to the next line as whole words. */ +html:lang(zh) [data-layer-id='directions'] .data-toggle-meta { + word-break: keep-all; + overflow-wrap: break-word; +} + +/* Leave room for the complete sensor name and resolution on two lines. */ +html:lang(zh) .recent-imagery-readout .ri-strip { + height: 131px; +} +html:lang(zh) .recent-imagery-readout .ri-card { + height: 123px; + grid-template-rows: 42px 13px 22px 11px 18px; +} +html:lang(zh) .recent-imagery-readout .ri-card-sensor { + white-space: normal; + text-overflow: clip; +}