diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..0a71ce3e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 830ca155e..95d7a6088 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -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/.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/.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. diff --git a/docs/translations/ja.md b/docs/translations/ja.md new file mode 100644 index 000000000..74abc49b5 --- /dev/null +++ b/docs/translations/ja.md @@ -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 | 安全の保証ではありません | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9f06f73f6..89c2596b5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/data/directions.test.mjs b/src/data/directions.test.mjs index bf6e2878e..38bada296 100644 --- a/src/data/directions.test.mjs +++ b/src/data/directions.test.mjs @@ -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', diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs index 1b421f2ff..2a3dcd39b 100644 --- a/src/i18n/i18n.test.mjs +++ b/src/i18n/i18n.test.mjs @@ -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 = {} } = {}) { diff --git a/src/i18n/index.js b/src/i18n/index.js index 6fc4becc9..40814f3a3 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -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. */ diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 55ed71163..26bd4abc8 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -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} diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index b8cceff02..1d5c100ae 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -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. */ diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 057642827..8f9f127e2 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -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' }); diff --git a/src/ui/unitLabels.js b/src/ui/unitLabels.js index 1d9bd6a61..3b12da006 100644 --- a/src/ui/unitLabels.js +++ b/src/ui/unitLabels.js @@ -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; } /** diff --git a/src/ui/unitLabels.test.mjs b/src/ui/unitLabels.test.mjs index c0b1932b1..14b6030e3 100644 --- a/src/ui/unitLabels.test.mjs +++ b/src/ui/unitLabels.test.mjs @@ -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'); + } +});