i18n(ja): ship Japanese

- Register ja: CATALOGS, LOCALES, LOCALE_NAMES (日本語) and the package
  boundaries; ja and ja-JP tags select it.
- Japanese unit words follow the number directly, as in the catalog:
  formatQuantity writes 5分 and 1時間5分 (km and kt keep the space), and
  formatRelativeTime drops the space CLDR puts in 5 分前.
- Tests: locale tags, picker order, Intl output, durations and the
  ramp/exit/lane wording in Japanese.
- docs/translations/ja.md: style, units, plurals and glossary; ja added to
  TRANSLATORS.md and CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-28 12:59:32 +04:00
1 parent 7ad073aa9c
commit ed40201481
12 files changed
+221 -16

No files matched your search

+1 -1
View File
@@ -3733,7 +3733,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in
Shipped locales: **en** (source and fallback), **es**, **ja**, **ru**, **zh-CN** and **zh-Hant**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Japanese** (`ja`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## 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), [Traditional Chinese](./translations/zh-Hant.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), [Japanese](./translations/ja.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.
+90
View File
@@ -0,0 +1,90 @@
# Japanese (`ja`)
Catalog: `src/i18n/locales/ja.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: abiyeenzo (#913).
## Style
- Sentences use です/ます: 「キーを保存しました」, 「もう一度お試しください」. Labels, buttons and HUD chips are short nouns or verb stems without a final 。: 「再試行」, 「保存」, 「ライブ」, 「オフ」.
- Japanese has no case: an UPPERCASE English label becomes the same short Japanese label. Codes, units and names stay in their own case (HDG, ADS-B, km).
- Full-width punctuation in Japanese text: 、。:?!()「」. Quote names and titles with 「」: 「{shot}」を削除しますか? Keep `·` as the HUD separator, `…` for an ellipsis and `—`, `→`, `↗` as in English. A dash inside a sentence becomes 。 or :.
- Button names cited in running text go in []: [次へ]を使ってください.
- No space between Japanese and Latin letters, digits or a `{placeholder}`: 「AIS船舶」, 「Spaceを長押しで話す」, 「{count}件」, 「{name}を表示」.
- Two exceptions keep the space: a Japanese label before its value in a readout (「雲量 {pct}%」, 「ステーション {id}」, 「高度 {altitude}」), and the state words in toggles (「CCTV オン」, 「ラジオ オフ」).
- Loanwords are katakana with the long-vowel mark, as in Microsoft and Apple style: ユーザー, ブラウザー, ビューアー, プロバイダー, レイヤー, フィルター, モニター, パラメーター. Pick one katakana form per term and keep it (see the glossary).
- Product, provider and dataset names stay in Latin script: Google Maps, Cesium ion, OpenSky, CelesTrak, NASA FIRMS, Sentinel-2, KartaView. Google's own product names use Google's Japanese: Google ストリートビュー. Nepal place names in the Bhote Koshi scenes stay in Latin script, as in zh-CN; country and well-known city names are translated (ネパール, 東京, オースティン).
## Numbers, units and dates
- Units: km, m, km/h, m/s, kt, ft, mph, hPa, MW, dB, °, % stay as written, with the English spacing (「250 km」, 「{frp} MW」, 「95 kt」).
- Time units are words written straight after the number: 秒, 分, 時間, 日 (「5分前」, 「{count}時間」, 「1時間5分」). `formatQuantity` and `formatRelativeTime` drop the space for Japanese unit words, so code-built values match the catalog.
- Counters follow the thing counted: 機 (aircraft), 隻 (ships), 基 (satellites), 台 (cameras, vehicles), 局 (stations), 件 (records, results, events), か所 (sites), 個 (generic). Prefer a counter to a bare number.
- Dates come from `Intl` and read year first: 2026年8月26日. Coordinates in prose are 北緯 / 東経; HUD hemisphere letters stay N, S, E, W.
## Plurals
`Intl.PluralRules('ja')` has one category, `other`: a `tn()` entry has only that form. Put the counter next to the number: 「航空機{n}機」, 「船舶{n}隻」, 「メッセージ {count}件」.
## Glossary
| English | 日本語 |
| ----------------------------------------- | --------------------------------------- |
| layer / data layer | レイヤー / データレイヤー |
| feed | フィード |
| source / data source / attribution | ソース / データソース / 出典 |
| LIVE (status) / live data | ライブ / ライブデータ |
| SIMULATED | シミュレーション |
| stale / degraded / unavailable | 更新遅延 / 性能低下 / 利用不可 |
| retry | 再試行 |
| key (API key) / POWER UP | キー(APIキー)/ パワーアップ |
| Provider Settings | プロバイダー設定 |
| globe / map / map source / basemap | 地球儀 / 地図 / マップソース / ベースマップ |
| mapped (OSM-mapped sites) | マップ済み |
| aircraft / flight / military flights | 航空機 / フライト / 軍用機 |
| vessel, ship | 船舶 |
| satellite | 衛星 |
| launch (rocket) / space mission | 打ち上げ / 宇宙ミッション |
| stage (rocket) / downrange | 段 / ダウンレンジ |
| contact (tracked object) | コンタクト |
| installation, site | 施設 |
| camera / CCTV camera | カメラ / CCTVカメラ |
| earthquake / active fire / fire perimeter | 地震 / 火災検知 / 延焼範囲 |
| contained (fire) | 鎮圧 |
| scene / shot | シーン / ショット |
| briefing / panel / dock | ブリーフィング / パネル / ドック |
| preset / visual preset | プリセット / ビジュアルプリセット |
| Tactical / Operator / Minimal / Cyber | タクティカル / オペレーター / ミニマル / サイバー |
| Scope (circular viewport mask) / keyhole | スコープ / キーホール |
| Clean UI | クリーンUI |
| cockpit | コックピット |
| heading / altitude / ground speed | 機首方位 / 高度 / 対地速度 |
| HDG / BRG / CRS / FOV | そのまま(HUDコード) |
| squawk | スコーク |
| map heading / North up | 方位 / 北を上に |
| Street Level / Street View (Google) | ストリートレベル / ストリートビュー |
| Recent Imagery | 最近の衛星画像 |
| Global Context / Context (panel) | グローバルコンテキスト / コンテキスト |
| radio (station) / tune / seek | ラジオ(局)/ 選局・同調 / シーク |
| transit / bikeshare | 公共交通 / シェアサイクル |
| Previous / Next | 前へ / 次へ |
| compass points (N, NNE, NE, ENE, …) | 北, 北北東, 北東, 東北東, … |
| weather field / FIELD (selector) | 要素 |
| weather model / forecast lead hours | モデル / 予報時間 |
| valid (forecast time) / issued | 対象時刻 / 発表 |
| radar reflectivity / lightning density | レーダー反射強度 / 雷密度 |
| cyclone / advisory (NHC/CPHC) | サイクロン / 情報(第N報) |
| forecast uncertainty cone | 予報円 |
| tropical storm / tropical depression | 熱帯暴風雨 / 熱帯低気圧 |
| scene cloud / mosaic / daily overview | シーン雲量 / モザイク / 日次概観 |
| box (Recent Imagery area) | 範囲 |
| pin (Recent Imagery) | 固定 |
| outline / mark (map annotation) | 輪郭 / マーク |
| share (a view or link) | 共有 |
| AI agent | AIエージェント |
| frame (bring into view) | 視野に収める |
| story beat / evidence beat | ストーリー区切り / 証拠の区切り |
| camera pose / camera move | カメラの姿勢 / 移動 |
| roundabout / ramp / exit | ロータリー / ランプ / 出口 |
| reduced motion | 動きを減らす |
| NOT AN ALL-CLEAR | 安全の保証ではありません |
+43
View File
@@ -440,6 +440,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -480,6 +481,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -510,6 +512,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -532,6 +535,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -549,6 +553,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -569,6 +574,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -596,6 +602,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -631,6 +638,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -648,6 +656,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -807,6 +816,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -929,6 +939,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -997,6 +1008,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -1046,6 +1058,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1109,6 +1122,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1139,6 +1153,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -1170,6 +1185,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1201,6 +1217,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -1235,6 +1252,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1278,6 +1296,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -1315,6 +1334,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -1366,6 +1386,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1396,6 +1417,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1445,6 +1467,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1509,6 +1532,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -1539,6 +1563,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1580,6 +1605,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1603,6 +1629,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1660,6 +1687,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -1723,6 +1751,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -2606,6 +2635,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3191,6 +3221,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3302,6 +3333,7 @@
"src/data/cameraArrival.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3326,6 +3358,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -3363,6 +3396,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3390,6 +3424,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -3482,6 +3517,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3538,6 +3574,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3793,6 +3830,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -3837,6 +3875,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -3891,6 +3930,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
@@ -3908,6 +3948,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -3968,6 +4009,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
@@ -4165,6 +4207,7 @@
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
+13
View File
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
);
const list = directionsStepList({ route });
assert.equal(list.items[1].lead, '240\u00a0м');
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
setLocale('ja');
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
assert.equal(routeDurationText(39), '39秒');
assert.equal(routeDurationText(3900), '1時間5分');
assert.equal(routeDurationText(7200), '2時間');
});
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
'Toma la salida bruscamente a la derecha',
'Toma el carril izquierdo',
],
[
'ja',
'ランプを斜め左に進んでI-95に入る',
'出口を急角度で右に出る',
'左車線を通行',
],
[
'zh-CN',
'向左前方驶入匝道,进入 I-95',
+10
View File
@@ -5,6 +5,7 @@ import {
applyDocumentTranslations,
formatDate,
formatNumber,
formatRelativeTime,
msg,
persistLocaleAndReload,
setLocale,
@@ -35,6 +36,9 @@ 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);
assert.equal(normalizeLocale('ja'), 'ja');
assert.equal(normalizeLocale('ja-JP'), 'ja');
assert.equal(normalizeLocale('JA_jp'), 'ja');
// Explicit script tags take precedence over regional defaults.
assert.equal(normalizeLocale('zh'), 'zh-CN');
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
@@ -164,7 +168,13 @@ test('Intl formatters follow the active locale', () => {
assert.equal(formatNumber(1234.5), '1,234.5');
setLocale('ru');
assert.equal(formatDate(date, options), '15 января 2026 г.');
assert.equal(formatRelativeTime(-5, 'minute'), '5 минут назад');
assert.equal(formatNumber(1234.5), '1 234,5');
// Japanese keeps a number and its unit together, as the catalog does.
setLocale('ja');
assert.equal(formatDate(date, options), '2026年1月15日');
assert.equal(formatRelativeTime(-5, 'minute'), '5分前');
assert.equal(formatRelativeTime(-3, 'day'), '3日前');
});
function fakeElement({ text = '', attributes = {} } = {}) {
+16 -3
View File
@@ -17,11 +17,18 @@ import {
writeStoredLocale,
} from './locale.js';
import es from './locales/es.json' with { type: 'json' };
import ja from './locales/ja.json' with { type: 'json' };
import ru from './locales/ru.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, 'zh-Hant': zhHant });
const CATALOGS = Object.freeze({
es,
ja,
ru,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE;
@@ -201,13 +208,19 @@ export function formatUtcDayMonth(value) {
/**
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад' /
* '5分前'.
*/
export function formatRelativeTime(value, unit = 'second', options) {
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
const text = cachedFormatter('relative', Intl.RelativeTimeFormat, {
numeric: 'auto',
...options,
}).format(value, unit);
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
// unit together (5分前), so the two must not differ on one screen.
return currentLocale === 'ja'
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
: text;
}
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
+10 -2
View File
@@ -8,12 +8,20 @@ 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', 'zh-CN', 'zh-Hant']);
export const LOCALES = Object.freeze([
'en',
'es',
'ja',
'ru',
'zh-CN',
'zh-Hant',
]);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
ja: '日本語',
ru: 'Русский',
'zh-CN': '中文(简体)',
'zh-Hant': '中文(繁體)',
@@ -23,7 +31,7 @@ export const LOCALE_NAMES = Object.freeze({
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', 'ja-JP' → 'ja',
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
* @returns {string|null}
+8 -2
View File
@@ -20,7 +20,11 @@ import * as Cesium from 'cesium';
import { roadLabel } from '../../data/routeSteps.js';
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import {
formatQuantity,
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -137,7 +141,9 @@ export function routeDurationText(seconds) {
if (minutes < 60) return formatQuantity(minutes, 'min');
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
const rest = minutes % 60;
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
if (!rest) return hours;
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
}
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
+4 -4
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
['ru', 'en', 'es', 'zh-CN', 'zh-Hant', 'ja'],
);
assert.deepEqual(
codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
['es', 'en', 'ru', 'zh-CN', 'zh-Hant', 'ja'],
);
assert.deepEqual(
codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
['en', 'es', 'ru', 'zh-CN', 'zh-Hant', 'ja'],
);
const rows = buildLocaleOptions({
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.equal(model.activeOption.code, 'en');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'zh-Hant');
assert.equal(model.activeOption.code, 'ja');
model.keydown({ key: 'Home' });
assert.equal(model.activeOption.code, 'ru');
model.keydown({ key: 'ArrowUp' });
+13 -2
View File
@@ -1,4 +1,4 @@
import { formatNumber, msg, t } from '../i18n/index.js';
import { formatNumber, getLocale, msg, t } from '../i18n/index.js';
// Unit ids stay English in data, params and AI-tool readings; only the label
// shown on the page is translated. dBZ and °C read the same in every language.
@@ -23,6 +23,17 @@ export function unitLabel(unit) {
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
}
/**
* What goes between a number and its unit label: a no-break space, except
* before a Japanese unit word, which follows the number directly (5分, 1時間).
*/
export function quantitySeparator(label) {
return getLocale() === 'ja' &&
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(label)
? ''
: '\u00a0';
}
/**
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
* no digit grouping ("1008 hPa"), as on weather charts.
@@ -30,7 +41,7 @@ export function unitLabel(unit) {
export function formatQuantity(value, unit, options) {
const label = unitLabel(unit);
const number = formatNumber(value, { useGrouping: false, ...options });
return label ? `${number}\u00a0${label}` : number;
return label ? `${number}${quantitySeparator(label)}${label}` : number;
}
/**
+11
View File
@@ -44,3 +44,14 @@ test('other languages get their decimal separator and no digit grouping', () =>
setLocale('en');
}
});
test('Japanese unit words follow the number directly; symbols keep the space', () => {
try {
setLocale('ja');
assert.equal(formatQuantity(5, 'min'), '5分');
assert.equal(formatQuantity(1, 'h'), '1時間');
assert.equal(formatQuantity(95, 'kt'), '95\u00a0kt');
} finally {
setLocale('en');
}
});