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; +}