Merge pull request #1430 from bilawalsidhu/codex/hal-646-zh-hant

i18n: add Chinese (Traditional) zh-Hant (#1430)

Co-authored-by: Enzo Abyie <[email protected]>
Co-authored-by: EDIT-Aeterna <[email protected]>
Co-authored-by: caxer <[email protected]>
Co-authored-by: tbdavid2019 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-28 12:14:29 +04:00
commit a4d3701c3c
18 files changed
+3139 -50

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- Add Traditional Chinese (zh-Hant) with language selection and localized
Chinese readouts: the Simplified Chinese catalog written in Traditional
characters, based on translations by @abiyeenzo (#914) and @EDIT-Aeterna
(#979) (German, @gekh, #1430).
+3 -3
View File
@@ -3733,7 +3733,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026) ### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The code lives in Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md). [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
@@ -3744,8 +3744,8 @@ Shipped locales: **en** (source and fallback), **es**, **ru** and **zh-CN**. The
value). English has no catalog; every other locale is value). English has no catalog; every other locale is
`src/i18n/locales/<code>.json`, and a missing entry falls back to English. `src/i18n/locales/<code>.json`, and a missing entry falls back to English.
- **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) → - **Locale choice.** `?lang=` → stored choice (`gev:locale:v1`) →
`navigator.languages` → English. Chinese tags map to `zh-CN`; tags containing `navigator.languages` → English. Chinese tags default to `zh-CN`; Traditional Chinese tags
`Hant`, `TW`, `HK` or `MO` are skipped. `?lang=` is not stored, and share links drop select `zh-Hant`. Explicit `Hans` selects `zh-CN` regardless of region. `?lang=` is not stored, and share links drop
it, so a recipient keeps their own language. The globe picker in the it, so a recipient keeps their own language. The globe picker in the
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and reloads. It asks first `?lang=` (keeps it only when storage is blocked) and reloads. It asks first
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View # Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`) and **Simplified Chinese** (`zh-CN`). Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works ## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes ## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md). Read it before translating, and add a term there when you settle on one. Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
+17
View File
@@ -0,0 +1,17 @@
# Traditional Chinese (`zh-Hant`)
Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
`zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs.
After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布.
## Style
- Follow [zh-CN.md](./zh-CN.md) for wording, punctuation, spacing, units and the glossary, written in Traditional characters.
- Relative-time placeholders may already include 前. In `LIVE · updated {age}`, the caller supplies the complete relative age; do not append another 前.
- The picker displays 中文(繁體) / Chinese (Traditional); explicit Hans tags select Simplified Chinese.
## Plurals
`Intl.PluralRules('zh-Hant')` uses only `other`. Keep the measure words of the `zh-CN` entry: 「{count} 架飛機」、「{count} 艘船」.
+63 -20
View File
@@ -441,7 +441,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -480,7 +481,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -510,6 +512,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/statusText.js", "src/ui/statusText.js",
@@ -530,7 +533,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -546,7 +550,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -566,6 +571,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/statusBadge.js" "src/ui/statusBadge.js"
], ],
@@ -592,6 +598,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
"external": [] "external": []
@@ -626,6 +633,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -642,6 +650,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": [] "external": []
@@ -800,6 +809,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
"src/ui/layerNames.js", "src/ui/layerNames.js",
@@ -921,6 +931,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -988,6 +999,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1035,7 +1047,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1097,7 +1110,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1127,6 +1141,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js" "src/layers/recentImagery/display.js"
], ],
@@ -1156,7 +1171,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1187,6 +1203,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium", "satellite.js"] "external": ["cesium", "satellite.js"]
@@ -1219,7 +1236,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1262,6 +1280,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/installations/classLabel.js" "src/layers/installations/classLabel.js"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
@@ -1298,6 +1317,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -1347,7 +1367,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile"] "external": ["cesium", "pbf", "@mapbox/vector-tile"]
}, },
@@ -1376,7 +1397,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1424,7 +1446,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["hls.js", "cesium"] "external": ["hls.js", "cesium"]
}, },
@@ -1488,6 +1511,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js" "src/layers/streetLevel/providers/mapillary/policy.js"
], ],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"] "external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -1516,7 +1540,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "@mapbox/vector-tile", "pbf"] "external": ["cesium", "@mapbox/vector-tile", "pbf"]
}, },
@@ -1556,7 +1581,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1578,7 +1604,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1634,7 +1661,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -1697,6 +1725,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/voice/providerCommands.js", "src/voice/providerCommands.js",
"src/voice/providerCapabilities.js", "src/voice/providerCapabilities.js",
@@ -2579,6 +2608,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js", "src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js", "src/ui/keyGuidance.js",
@@ -3163,6 +3193,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
"src/layers/recentImagery/display.js", "src/layers/recentImagery/display.js",
"src/layers/installations/classLabel.js", "src/layers/installations/classLabel.js",
@@ -3273,6 +3304,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/i18n/index.js" "src/i18n/index.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3295,7 +3327,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -3332,6 +3365,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
"src/ui/displayControls.js" "src/ui/displayControls.js"
], ],
@@ -3357,7 +3391,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": [] "external": []
}, },
@@ -3449,6 +3484,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -3504,6 +3540,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/voice/providerCapabilities.js" "src/voice/providerCapabilities.js"
], ],
@@ -3757,7 +3794,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -3800,7 +3838,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium", "pbf"] "external": ["cesium", "pbf"]
}, },
@@ -3853,7 +3892,8 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json" "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
], ],
"external": ["cesium"] "external": ["cesium"]
}, },
@@ -3870,6 +3910,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
@@ -3929,6 +3970,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/data/geoImportImages.js", "src/data/geoImportImages.js",
"src/data/geoImportPins.js", "src/data/geoImportPins.js",
"src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/tileMath.js",
@@ -4125,6 +4167,7 @@
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
"src/ui/layerNames.js" "src/ui/layerNames.js"
], ],
+2 -1
View File
@@ -16,7 +16,8 @@ export function hudQuantity(value, unit, digits = 0) {
maximumFractionDigits: digits, maximumFractionDigits: digits,
}); });
const label = unitLabel(unit); const label = unitLabel(unit);
if (getLocale() === 'zh-CN') return label ? `${number} ${label}` : number; if (getLocale().startsWith('zh-'))
return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`; return `${number}${label.toUpperCase()}`;
} }
+6 -4
View File
@@ -5,12 +5,12 @@ import { createModel } from '../layers/firms/model.js';
import { createQueries } from '../layers/firms/queries.js'; import { createQueries } from '../layers/firms/queries.js';
const cases = [ const cases = [
[0, '不到 1 分钟前', '<1m ago'], [0, '不到 1 分钟前', '<1m ago', '不到 1 分鐘前'],
[120000, '2 分钟前', '2m ago'], [120000, '2 分钟前', '2m ago', '2 分鐘前'],
[7200000, '2 小时前', '2h ago'], [7200000, '2 小时前', '2h ago', '2 小時前'],
]; ];
for (const [age, zh, en] of cases) { for (const [age, zh, en, hant] of cases) {
test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => { test(`FIRMS live age ${age} ms has one relative-time suffix`, (context) => {
const now = 1800000000000; const now = 1800000000000;
context.mock.method(Date, 'now', () => now); context.mock.method(Date, 'now', () => now);
@@ -36,6 +36,8 @@ for (const [age, zh, en] of cases) {
assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`); assert.equal(methods.getStats().loadingLabel, `LIVE · updated ${en}`);
setLocale('zh-CN'); setLocale('zh-CN');
assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`); assert.equal(methods.getStats().loadingLabel, `实时 · 更新于 ${zh}`);
setLocale('zh-Hant');
assert.equal(methods.getStats().loadingLabel, `實時 · 更新於 ${hant}`);
} finally { } finally {
setLocale(previousLocale); setLocale(previousLocale);
} }
+9 -3
View File
@@ -35,12 +35,18 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null); assert.equal(normalizeLocale('xx'), null);
// Simplified Chinese by default; Traditional tags wait for zh-TW. // Explicit script tags take precedence over regional defaults.
assert.equal(normalizeLocale('zh'), 'zh-CN'); assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN'); assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN'); assert.equal(normalizeLocale('zh-Hans-SG'), 'zh-CN');
assert.equal(normalizeLocale('zh-TW'), null); assert.equal(normalizeLocale('zh-Hant'), 'zh-Hant');
assert.equal(normalizeLocale('zh-Hant-HK'), null); assert.equal(normalizeLocale(' ZH_hant '), 'zh-Hant');
assert.equal(normalizeLocale('zh-TW'), 'zh-Hant');
assert.equal(normalizeLocale('zh-Hant-HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh-Hans-HK'), 'zh-CN');
assert.equal(normalizeLocale('zh-Hant-CN'), 'zh-Hant');
assert.equal(normalizeLocale('zh_HK'), 'zh-Hant');
assert.equal(normalizeLocale('zh-MO'), 'zh-Hant');
assert.equal(normalizeLocale(undefined), null); assert.equal(normalizeLocale(undefined), null);
}); });
+2 -1
View File
@@ -19,8 +19,9 @@ import {
import es from './locales/es.json' with { type: 'json' }; import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN }); const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
let currentLocale = DEFAULT_LOCALE; let currentLocale = DEFAULT_LOCALE;
+9 -6
View File
@@ -8,14 +8,15 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN']); export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']);
/** Each locale named in its own language, for the selector. */ /** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({ export const LOCALE_NAMES = Object.freeze({
en: 'English', en: 'English',
es: 'Español', es: 'Español',
ru: 'Русский', ru: 'Русский',
'zh-CN': '简体中文', 'zh-CN': '中文(简体)',
'zh-Hant': '中文(繁體)',
}); });
/** Right-to-left locales. */ /** Right-to-left locales. */
@@ -23,17 +24,19 @@ 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'. * 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
* @returns {string|null} * @returns {string|null}
*/ */
export function normalizeLocale(candidate) { export function normalizeLocale(candidate) {
if (typeof candidate !== 'string') return null; if (typeof candidate !== 'string') return null;
const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/); const [primary, ...rest] = candidate.trim().toLowerCase().split(/[-_]/);
// Traditional Chinese readers are not given Simplified: no zh-TW yet. if (primary === 'zh') {
if (primary === 'zh') if (rest.includes('hans')) return 'zh-CN';
return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part)) return rest.some((part) => ['hant', 'tw', 'hk', 'mo'].includes(part))
? null ? 'zh-Hant'
: 'zh-CN'; : 'zh-CN';
}
return LOCALES.includes(primary) ? primary : null; return LOCALES.includes(primary) ? primary : null;
} }
File diff suppressed because it is too large. Load diff
+57
View File
@@ -198,5 +198,62 @@
"ΔE", "ΔE",
"ΔN", "ΔN",
"▲ {frp} MW" "▲ {frp} MW"
],
"zh-Hant": [
"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}",
"S2 GhostMaps",
"S2 · {name}",
"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"
] ]
} }
+80
View File
@@ -0,0 +1,80 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setLocale, t, tn, getLocale } from './index.js';
import {
resolveLocale,
LOCALE_STORAGE_KEY,
writeStoredLocale,
applyDocumentLanguage,
} from './locale.js';
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
import { READOUT_WORDS } from '../layers/recentImagery/display.js';
import { cctvSourceStatusLabel } from '../ui/cctvFrames.js';
test('Traditional Chinese resolves from URL, saved preference and browser language', () => {
assert.equal(
resolveLocale({
location: { search: '?lang=zh-TW' },
storage: { getItem: () => 'en' },
languages: ['en'],
}),
'zh-Hant',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: {
getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'zh-TW' : null),
},
languages: ['en'],
}),
'zh-Hant',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: { getItem: () => null },
languages: ['zh-Hant', 'en'],
}),
'zh-Hant',
);
});
test('Traditional Chinese persists and exposes the script locale for legacy choices', () => {
const stored = new Map();
assert.equal(
writeStoredLocale('zh-TW', {
setItem: (key, value) => stored.set(key, value),
}),
true,
);
assert.equal(stored.get(LOCALE_STORAGE_KEY), 'zh-Hant');
const doc = { documentElement: {} };
assert.equal(applyDocumentLanguage(doc, 'zh-TW'), true);
assert.equal(doc.documentElement.lang, 'zh-Hant');
assert.equal(doc.documentElement.dir, 'ltr');
});
test('Traditional Chinese readouts preserve unit case, coordinates and natural relative dates', () => {
const previous = getLocale();
try {
setLocale('zh-Hant');
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',
);
assert.equal(READOUT_WORDS.daysAgo(9), '9 天前');
assert.equal(READOUT_WORDS.daysAgo(0), '今天');
assert.equal(cctvSourceStatusLabel('ok'), '正常');
assert.equal(t('SAVE KEYS'), '保存密鑰');
assert.equal(
tn(2, 'Click {count} more point.', 'Click {count} more points.'),
'再點擊 2 個點。',
);
} finally {
setLocale(previous);
}
});
+3 -1
View File
@@ -33,7 +33,9 @@ export const READOUT_WORDS = Object.freeze({
date: (day) => displayDay(day, LONG_DAY), date: (day) => displayDay(day, LONG_DAY),
daysAgo: (delta) => { daysAgo: (delta) => {
const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day'); const text = formatRelativeTime(delta > 0 ? -delta : 0, 'day');
return getLocale() === 'zh-CN' ? text.replace(/(\d)(?=天)/g, '$1 ') : text; return getLocale().startsWith('zh-')
? text.replace(/(\d)(?=天)/g, '$1 ')
: text;
}, },
// "Sentinel-2" never breaks at its hyphen: a word joiner follows it // "Sentinel-2" never breaks at its hyphen: a word joiner follows it
// (JetBrains Mono has no U+2011 non-breaking hyphen). // (JetBrains Mono has no U+2011 non-breaking hyphen).
+1 -1
View File
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) => window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', { t('{label} · {radius} AIR/SEA WINDOW', {
label, label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`, radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
}), }),
installations: ({ scope, coverage }) => installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }), t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
+1 -1
View File
@@ -149,7 +149,7 @@ export function formatCockpitDistance(distanceM) {
minimumFractionDigits: digits, minimumFractionDigits: digits,
maximumFractionDigits: digits, maximumFractionDigits: digits,
}); });
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase(); return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
} }
export function formatCockpitWindDirection(value) { export function formatCockpitWindDirection(value) {
+9 -3
View File
@@ -20,6 +20,11 @@ export const LOCALE_SEARCH_THRESHOLD = 8;
/** Type-ahead keystrokes closer together than this extend one prefix. */ /** Type-ahead keystrokes closer together than this extend one prefix. */
export const TYPEAHEAD_RESET_MS = 500; export const TYPEAHEAD_RESET_MS = 500;
const CHINESE_LABELS = {
'zh-CN': 'Chinese (Simplified)',
'zh-Hant': 'Chinese (Traditional)',
};
/** /**
* Text folded for search: no diacritics, lower case. 'Español' → 'espanol', * Text folded for search: no diacritics, lower case. 'Español' → 'espanol',
* 'Ёжик' → 'ежик'. * 'Ёжик' → 'ежик'.
@@ -464,12 +469,13 @@ export function mountLocalePicker({
item.dataset.locale = option.code; item.dataset.locale = option.code;
const name = element(documentRef, 'span', 'locale-picker-native'); const name = element(documentRef, 'span', 'locale-picker-native');
name.textContent = option.nativeName; name.textContent = option.nativeName;
// Every row reads the same: the language named in itself, then its code. // Chinese variants are distinguished by script rather than region.
// The interface-language name only helps the search find it.
const meta = element(documentRef, 'span', 'locale-picker-meta'); const meta = element(documentRef, 'span', 'locale-picker-meta');
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code'); const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
codeLabel.setAttribute('translate', 'no'); codeLabel.setAttribute('translate', 'no');
codeLabel.textContent = option.code.toUpperCase(); codeLabel.textContent =
CHINESE_LABELS[option.code] || option.code.toUpperCase();
if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en';
meta.append(codeLabel); meta.append(codeLabel);
const text = element(documentRef, 'span', 'locale-picker-text'); const text = element(documentRef, 'span', 'locale-picker-text');
text.append(name, meta); text.append(name, meta);
+4 -4
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['ru', 'en', 'es', 'zh-CN'], ['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
); );
assert.deepEqual( assert.deepEqual(
codes( codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['es', 'en', 'ru', 'zh-CN'], ['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
); );
assert.deepEqual( assert.deepEqual(
codes( codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['en', 'es', 'ru', 'zh-CN'], ['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
); );
const rows = buildLocaleOptions({ const rows = buildLocaleOptions({
locales: MANY, locales: MANY,
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en'); assert.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' }); model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'zh-CN'); assert.equal(model.activeOption.code, 'zh-Hant');
model.keydown({ key: 'Home' }); model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru'); assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' }); model.keydown({ key: 'ArrowUp' });