Merge pull request #1479 from bilawalsidhu/german/hal-644-ar

i18n: Arabic (ar), the first right-to-left language
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-01 20:44:13 -08:00
commit 92bb1d6a89
62 files changed
+4499 -185

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
- Add Arabic (ar), the first right-to-left language: the title, rails, dock and
corner row mirror, numbers keep Western digits, and plurals use all six
Arabic forms. FIRMS cards and Space Missions landing labels are now
translated in every language. Based on translations by @abiyeenzo (#915)
(German, @gekh, #1479).
+10 -2
View File
@@ -3866,7 +3866,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **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).
@@ -3880,11 +3880,19 @@ Shipped locales: **en** (source and fallback), **de**, **es**, **fr**, **it**, *
`navigator.languages` → English. Any `pt` tag (`pt-BR`, `pt-PT`) selects `pt`. Chinese tags default to `zh-CN`; Traditional Chinese tags
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
bottom-right corner (`src/ui/localePicker.js`) stores the choice, drops
bottom-right corner (bottom-left in Arabic) (`src/ui/localePicker.js`) stores the choice, drops
`?lang=` (keeps it only when storage is blocked) and switches in place:
views re-render, code that published translated text words it again through
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
follow. Nothing is lost; a toast already on screen keeps its words.
- **Right to left.** Arabic (`ar`) is in `RTL_LOCALES`, so `<html dir="rtl">`:
the title, style indicator, both panel stacks and their icon rails swap
sides through logical properties (`inset-inline-*`, `margin-inline-*`), the
dock and corner row reverse their order, and `src/ui/styles/rtl.css` holds
the rest: no letter-spacing on Arabic text, Noto Sans Arabic in the font
stacks, the intel HUD kept as a left-to-right instrument overlay, FM seek
and the radio dial kept left to right. Intl formatters get `ar-u-nu-latn`,
so numbers and dates keep Western digits.
- **Gate.** `npm run i18n` (and `src/i18n/catalog.test.mjs` under `npm test`)
extracts every message and fails on missing, empty, unused,
placeholder-mismatched, still-English (plural forms included) or
+3 -3
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View
Shipped languages: **English** (source and fallback), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works
@@ -44,7 +44,7 @@ Edit `src/i18n/locales/<code>.json` and run `npm run i18n`. To see your change,
1. Create `src/i18n/locales/<code>.json`, fill it from `npm run i18n -- --missing <code>` (start from `{}`), and translate every entry.
2. Import it in `src/i18n/index.js` and add it to `CATALOGS`.
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`.
3. Add the code to `LOCALES` and its own-language name to `LOCALE_NAMES` in `src/i18n/locale.js`. Right-to-left languages also go in `RTL_LOCALES`; the chrome mirrors through logical CSS properties (`inset-inline-*`, `margin-inline-*`), and `src/ui/styles/rtl.css` holds what those cannot express (see the Arabic notes). If the language should keep Western digits, map it in `INTL_TAGS` in `locale.js`.
4. Add `src/i18n/locales/<code>.json` next to `ru.json` in `scripts/package-boundaries.json`.
5. Write `docs/translations/<code>.md`: glossary, plural forms, units and style (copy the structure of `ru.md`).
6. Run `npm run i18n` and `npm test`, then check the panels in the app at narrow widths.
@@ -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`: [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.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`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.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.
+117
View File
@@ -0,0 +1,117 @@
# Arabic (`ar`)
Catalog: `src/i18n/locales/ar.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation sources: abiyeenzo (#915).
## Style
- Modern Standard Arabic, neutral across regions. Address the user with plain imperatives: «انقر»، «فعّل»، «أعد المحاولة».
- Arabic has no letter case: an UPPERCASE English HUD label becomes the same short Arabic label. Keep labels as short as the English: «مباشر»، «متقادم»، «متوقف».
- Status words in progress use «جارٍ …» («جارٍ التحميل»، «جارٍ الضبط»، «… جارٍ تشغيل اللقطة»), so a status chip never reads as the command next to it («ضبط» is the TUNE button); finished actions use the passive («تم الحفظ»، «أُلغي التحميل»). Data that refreshes itself «يُحدَّث» (يتحدّث means "speaks"). A sentence about a `{placeholder}` whose gender and number vary (tool, layer and cohort names, call signs, vessel names) stays impersonal: «{layers}: تم التفعيل للتو»، ««{name}» قيد الاستخدام»، «{id}: لا بيانات واردة الآن»، «لم يعد {name} ضمن البيانات المحمّلة».
- A label followed by its value takes a colon, so it does not read as a sentence: «الصوت: نشط»، «السياق: {value}»، «الاحتواء: {percent}%». A named mode or control quoted in a sentence takes «»: «يظهر في وضع «كثيف» فقط».
- Arabic punctuation in Arabic text: ، ؛ ؟ and «» for quotes. Keep `·` as the HUD separator, `—` with spaces, and `…`.
- Arrows that show a sequence point the way Arabic reads: «الإشارات ← الأخبار ← المحلي», «{origin} ← {destination}».
- Product names, provider names and codes stay in Latin letters: Google, Cesium ion, CelesTrak, NOAA, FIRMS, AIS, ADS-B, GNSS, CCTV, HUD, CRT, NVG, FLIR, WHOT/BHOT, Ironbow, Street View, Mapillary. Aviation and camera abbreviations stay Latin too: HDG, FOV, BRG, CRS, GS, TRK, V/S, SQUAWK. Local place names from the Bhote Koshi scene (Rasuwagadhi, Bidur, Timure…) stay Latin, and a site known by two names takes one Arabic noun in front: «جسر Mailung / Upper Trishuli»; well-known cities and countries are written in Arabic (أوستن، طوكيو، نيبال).
- Months are the international names used across the Arab world: يناير، فبراير … أغسطس.
## Digits and direction
- **Western digits (0–9)**, as in the catalog. The HUD reads like an instrument panel, and mixing ٠–٩ with Latin codes and coordinates misreads. `intlLocale('ar')` in `src/i18n/locale.js` passes `ar-u-nu-latn` to every Intl formatter, so browsers that default to Arabic-Indic digits for an Arabic region still show 0–9.
- **Right to left.** `ar` is in `RTL_LOCALES`, so the page gets `<html dir="rtl">`. The title, style indicator, both panel stacks and their icon rails swap sides; the dock and the corner row reverse. The intel HUD (classification block, MGRS, coordinates, GSD) stays a left-to-right instrument overlay; its translated lines read right to left inside it. FM seek and the radio dial stay left to right, as on any receiver. The rails' screen-reader names follow the mirrored layout: «Left panels» is «اللوحات اليمنى». Layout rules: `src/ui/styles/rtl.css`.
- Latin runs inside Arabic sentences (product names, codes, `{placeholders}` filled with English data) are isolated by the bidi algorithm; write them as plain text, without manual direction marks.
- Position counters: a translated counter says «من» («القناة {index} من {total}», «الإطار {index} من {total}»); a spaced slash between two isolated numbers swaps them in right-to-left text. Counters the code builds from bare numbers (`01 / 25`, `1 / 3`) are shown left to right by `rtl.css`.
- Code stays left to right: the JSON fields of the scene editor are LTR in every language.
- Comparison signs `<` and `>` are mirrored glyphs in right-to-left text («< 2%» shows as «> 2%»). Write them as words: «أقل من 2%»، «أكثر من 10%»، «أقل من ساعة». A legend's open upper bound («300+», «-40») is isolated by `formatScaleLabel()`, so its sign stays where it is written; `formatScaleRange()` isolates the unit after the range too («°C», not «C°»).
## Units
Distances and speeds take Arabic abbreviations after the number, with a space: كم، م، كم/س، كم/ث، م/ث، ميل/س، عقدة (knots)، قدم (feet)، مم. Time: ث (seconds)، د (minutes)، س (hours)، يوم; compact age labels («3d», «5D AGO») use ي: «{count} ي»، «قبل {count} ي», since a plain string cannot make يوم agree with the count. Area: آكر (acres, the value is not converted; فدان is a different unit). Money: the word دولار after the amount, not a bare `$`: «{amount} مليون دولار». Scientific and radio units stay Latin: MHz، dB، dBFS، dBZ، hPa، MW، MiB. `°` and `%` stay. Hemisphere letters in coordinates: ش (N)، ج (S)، ق (E)، غ (W), as in `21.33ش`.
## Plurals
`Intl.PluralRules('ar')` uses all six CLDR categories, and a `tn()` entry needs every one:
| Form | Counts | Example |
| ------- | --------------------------- | ------------- |
| `zero` | 0 | 0 مصدر |
| `one` | 1 | مصدر واحد (1) |
| `two` | 2 | مصدران (2) |
| `few` | 3–10, 103–110… | 3 مصادر |
| `many` | 11–99, 111–199… | 11 مصدرًا |
| `other` | 100–102, 200–202, fractions | 100 مصدر |
Every form must keep `{count}`. For one and two, Arabic names the quantity with the noun itself (مصدر واحد، مصدران), so the number follows in parentheses. `few` takes the plural noun; `many` the accusative singular (مصدرًا، مفتاحًا); `zero` and `other` the genitive singular. When a word next to the number cannot agree in every form, or a short chip has no room, put the number after a colon instead: «القريبة: {count}»، «الملفات المحددة: {count}». Where neither fits, «من أصل {total} من السجلات» agrees with any number. A standalone unit label («الدقائق») is never glued after a number: the voice-timeout options («3 دقائق»، «15 دقيقة») come from `Intl.NumberFormat` unit formatting, not from the catalog.
## Glossary
| English | العربية |
| ------------------------------------------ | ---------------------------------------------------------- |
| layer / data layer | طبقة / طبقة بيانات |
| feed / source | مصدر بيانات (short: مصدر) / مصدر |
| LIVE (status) / live data | مباشر / البيانات المباشرة (not حي) |
| Live ships, live detections (descriptions) | بيانات مباشرة عن السفن، رصد مباشر (not the adverb مباشرةً) |
| track, tracking | تتبّع (always with the shadda) |
| SIMULATED | محاكى (status) / محاكاة |
| stale / degraded / unavailable | متقادم / متدهور / غير متاح |
| ON / OFF (state) | مفعّل / متوقف |
| retry | إعادة المحاولة |
| key (API key) / POWER UP | مفتاح (مفتاح API) / تشغيل |
| Provider Settings | إعدادات المزوّدين |
| globe / map / map source / basemap | الكرة الأرضية / الخريطة / مصدر الخريطة / الخريطة الأساسية |
| globe map (map source drawn on the globe) | خريطة على الكرة الأرضية (not خريطة كروية) |
| tiles | بلاطات |
| aircraft / flight / military flights | طائرة / رحلة / الرحلات العسكرية |
| Live Flights / Live Vessels (layers) | تتبّع الرحلات / السفن المباشر (not الرحلات المباشرة) |
| call sign | رمز النداء |
| vessel, ship | سفينة |
| satellite | قمر صناعي |
| launch (rocket) / space mission | إطلاق / مهمة فضائية |
| contact (tracked object) | هدف |
| installation, site / mapped | منشأة، موقع / مرسوم |
| camera / CCTV camera | كاميرا / كاميرا CCTV |
| earthquake / active fire / fire perimeter | زلزال / حريق نشط / محيط حريق |
| containment (wildfire) | احتواء (not السيطرة) |
| cyclone advisory | نشرة إعصار |
| scene / shot / scene recipe | مشهد / لقطة / قالب المشهد |
| briefing / panel / dock | إحاطة / لوحة / الشريط السفلي |
| preset / visual preset | إعداد مسبق / نمط بصري |
| Scope (circular viewport mask) / keyhole | المنظار / الفتحة |
| Clean UI / EXIT CLEAN VIEW | واجهة نظيفة / الخروج من الواجهة النظيفة (not العرض) |
| cockpit | القمرة (قمرة القيادة) |
| heading / altitude / ground speed | الاتجاه (HDG) / الارتفاع / السرعة الأرضية |
| Street Level / Street View | مستوى الشارع / Street View |
| Recent Imagery | الصور الحديثة |
| Global Context / Context | السياق العالمي / السياق |
| radio station / tuner / gain | محطة / موالف / الكسب |
| broadcaster (radio stream) | محطة البث (not المحطة المُذيعة) |
| VOLUME (slider) / VOICE | مستوى الصوت / الصوت |
| decoder (SDR) | مفكّك |
| transit / bike share | النقل العام / الدراجات المشتركة |
| Previous / Next | السابق / التالي |
| compass points (N, NNE, …) | شمال، شمال شمال شرق، … |
| forecast / valid (time) / issued | توقع / عند، وقت التوقع (not صالح حتى) / صدر |
| observation / observed | رصد / مرصود |
| heard (aircraft received by the SDR) | الملتقطة |
| upstream (camera feed) | المصدر الأصلي (in every CCTV message, not bare المصدر) |
| field (weather) / model | حقل / نموذج |
| parameters / ellipsoid | المعلمات / المجسّم الإهليلجي |
| scene cloud | غيوم المشهد |
| box (Recent Imagery area) / pin | منطقة / تثبيت |
| width (box size) / view (current camera) | اتساع / العرض الحالي (عرض alone reads as display) |
| N/A | غير متاح (no abbreviation) |
| METRO (view band) / REGIONAL | حضري (not حاضرة) / إقليمي |
| THIS (voice pointer) | هذا |
| outline / mark (map annotation) | مخطط / علامة، تعليم |
| share (a view or link) | مشاركة |
| AI agent | وكيل ذكاء اصطناعي |
| spacecraft | مركبة فضائية (مركبة alone is any vehicle) |
| stage (rocket) / downrange | مرحلة / على امتداد المسار |
| landing position: CONFIRMED / PAD (RTLS) | مؤكَّد / منصة الإطلاق |
| recovery (rocket stage) | استعادة (not استرجاع) |
| Arrive at your destination (route step) | الوصول إلى وجهتك (a step, not وصلت) |
| forecast lead hours | مدة التوقع بالساعات (not ساعات المهلة) |
| cel-shaded | بتظليل كرتوني |
| (debris-)dammed lake | بحيرة خلف سدّ (ركامي) |
| frame (bring into view) | إظهار في الإطار |
| story beat | مرحلة القصة |
+1 -1
View File
@@ -21,7 +21,7 @@
<link rel="stylesheet" href="/style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap" rel="stylesheet" />
<!-- Icon font subset to the glyphs this app renders. Keep `icon_names` in sync
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
+44
View File
@@ -60,6 +60,7 @@
"src/ui/scenePresentation.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -570,6 +571,7 @@
"src/ui/rail/rangeValue.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -617,6 +619,7 @@
"src/localRequestGate.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -655,6 +658,7 @@
"src/data/layerSnapshot.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -696,6 +700,7 @@
"src/app/stateChannel.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -722,6 +727,7 @@
"src/ui/radioTunerModel.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -748,6 +754,7 @@
"src/ui/cctvPresentation.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -784,6 +791,7 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -829,6 +837,7 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -854,6 +863,7 @@
"src/ui/views/SceneShotList.vue",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1025,6 +1035,7 @@
"src/data/cameraArrival.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1204,6 +1215,7 @@
"src/data/cameraArrival.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1285,6 +1297,7 @@
"src/data/modelEligibility.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1340,6 +1353,7 @@
"src/data/modelEligibility.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1409,6 +1423,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1445,6 +1460,7 @@
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1482,6 +1498,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1519,6 +1536,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1559,6 +1577,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1608,6 +1627,7 @@
"src/data/infrastructureOverlayEntry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1651,6 +1671,7 @@
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1708,6 +1729,7 @@
"src/layers/traffic/roadModes.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1744,6 +1766,7 @@
"src/data/cameraSensitivity.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1799,6 +1822,7 @@
"src/layers/cctv/sourcePolicy.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1869,6 +1893,7 @@
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1905,6 +1930,7 @@
"src/layers/alpr/floors.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1952,6 +1978,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -1981,6 +2008,7 @@
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -2044,6 +2072,7 @@
"src/maps/imageryHost.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -2113,6 +2142,7 @@
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -3014,6 +3044,7 @@
"src/app/embed.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -3672,6 +3703,7 @@
"src/sources/infrastructureData.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -3799,6 +3831,7 @@
"src/app/stateChannel.js",
"src/data/cameraArrival.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -3829,6 +3862,7 @@
"src/ui/uiLifetime.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -3873,6 +3907,7 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -3910,6 +3945,7 @@
"src/ui/uiLifetime.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4014,6 +4050,7 @@
"src/data/routeSteps.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4076,6 +4113,7 @@
"src/voice/pointerReticle.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4341,6 +4379,7 @@
"src/sources/capability.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4391,6 +4430,7 @@
"src/sources/transitHistory.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4451,6 +4491,7 @@
"src/layers/perimeters/inciweb.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4474,6 +4515,7 @@
"src/layers/gnss/records.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4540,6 +4582,7 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
@@ -4745,6 +4788,7 @@
"src/i18n/marker.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
"src/i18n/locales/fr.json",
+5 -4
View File
@@ -112,10 +112,11 @@ test('the DISPLAY rail styles live in their current owner, not the shim', () =>
// these rules lived in the root style.css. That file is an import shim now,
// and putting rules back into it would silently undo the split.
const shim = read('style.css');
assert.ok(
shim.split('\n').length < 40,
'style.css is an import shim, not a stylesheet',
);
const rules = shim
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.filter((line) => line.trim() && !/^@import '[^']+';$/.test(line.trim()));
assert.deepEqual(rules, [], 'style.css is an import shim, not a stylesheet');
assert.doesNotMatch(
shim,
/draw-hint|pp-text-input/,
+15 -14
View File
@@ -569,7 +569,8 @@ function anchorOnScreen(viewer, anchor, root) {
return { x, y, width: rootBounds.width, height: rootBounds.height };
}
function placeCard(card, leader, anchor) {
/** Place a media card beside its map anchor, in the lane between the panels. */
export function placeCard(card, leader, anchor) {
const margin = 18;
const gap = 42;
const width = card.offsetWidth || 392;
@@ -580,22 +581,22 @@ function placeCard(card, leader, anchor) {
let laneRight = anchor.width - margin;
let laneBottom = anchor.height - margin;
if (rootBounds && documentRef?.querySelector) {
const leftPanel = documentRef
.querySelector('#scene-panel')
?.getBoundingClientRect?.();
const rightRail = documentRef
.querySelector('#right-context-rail')
?.getBoundingClientRect?.();
const commandDock = documentRef
.querySelector('#command-dock')
?.getBoundingClientRect?.();
if (leftPanel?.width > 0)
laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
if (rightRail?.width > 0)
laneRight = Math.min(
laneRight,
rightRail.left - rootBounds.left - margin,
);
// The scene panel and the context rail swap sides in right-to-left
// languages: each narrows the lane from the side it sits on.
for (const selector of ['#scene-panel', '#right-context-rail']) {
const panel = documentRef
.querySelector(selector)
?.getBoundingClientRect?.();
if (!(panel?.width > 0)) continue;
const start = panel.left - rootBounds.left;
const end = panel.right - rootBounds.left;
if (start + end < rootBounds.width)
laneLeft = Math.max(laneLeft, end + margin);
else laneRight = Math.min(laneRight, start - margin);
}
if (commandDock?.height > 0)
laneBottom = Math.min(
laneBottom,
+23
View File
@@ -6,6 +6,7 @@ import {
createBhoteKoshiEmbeddedMedia,
createTrimmedYouTubePlayback,
embeddedMediaFrameUrl,
placeCard,
publicEmbeddedMediaUrl,
resolveEmbeddedMediaSource,
} from './bhoteKoshiEmbeddedMedia.js';
@@ -394,3 +395,25 @@ test('pausing cancels a mounted Facebook preload and rejects its late ready call
assert.deepEqual(playerCalls, []);
media.destroy();
});
test('a media card stays between the panels when right-to-left swaps their sides', () => {
const rect = (left, right, top = 0, bottom = 900) => ({ left, right, top, bottom, width: right - left, height: bottom - top });
// Arabic: the scene panel sits on the right, the context rail on the left.
const panels = {
'#scene-panel': rect(1040, 1420),
'#right-context-rail': rect(20, 340),
'#command-dock': rect(500, 940, 820, 880),
};
const card = {
offsetWidth: 392,
offsetHeight: 360,
parentElement: { getBoundingClientRect: () => rect(0, 1440) },
ownerDocument: { querySelector: (selector) => ({ getBoundingClientRect: () => panels[selector] }) },
classList: { contains: () => false },
style: {},
};
placeCard(card, { querySelector: () => null }, { x: 700, y: 400, width: 1440, height: 900 });
const left = parseFloat(card.style.left);
assert.ok(left >= 340 + 18, `card at ${left}px lands on the context rail`);
assert.ok(left + 392 <= 1040 - 18, `card at ${left}px lands on the scene panel`);
});
+5 -2
View File
@@ -12,7 +12,7 @@ import {
} from './dataCredits.js';
import { BHOTE_KOSHI_FLOOD_PATH } from './bhoteKoshiFloodPath.js';
import { INCIDENT_OVERVIEW_PLACES } from './bhoteKoshiIncidentPlaces.js';
import { t, msg, getLocale } from '../i18n/index.js';
import { t, msg, getLocale, isolate } from '../i18n/index.js';
const BHOTE_KOSHI_LOCATOR_LAYER_ID = 'bhote-koshi-locator';
export const BHOTE_KOSHI_LOCATOR_OVERLAY_SOURCE_ID =
@@ -67,7 +67,10 @@ const PLACE_TITLE_TEXT = new Map(
function placeTitle(title) {
const text = PLACE_TITLE_TEXT.get(title);
return text ? t(text).toLocaleUpperCase(getLocale()) : title;
// Isolated: the card draws left to right after its "11 · " counter, so in
// Arabic a translated title with a Latin place name («نقطة الإطلالة في
// DANDAGAUN») must keep its own right-to-left order.
return text ? isolate(t(text).toLocaleUpperCase(getLocale())) : title;
}
const INCIDENT = Object.freeze({
+25
View File
@@ -14,6 +14,7 @@ import {
FIRMS_OVERLAY_SOURCE_ID,
} from './firmsLabels.js';
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
const NOW = Date.UTC(2026, 6, 17, 4, 0);
@@ -100,6 +101,30 @@ test('buildCellCard: plural noun, max FRP and newest age, accent passthrough', (
assert.equal(card.accent, accentForSeverity('orange'));
});
test('buildCellCard: the count picks the plural form of the language', () => {
const title = (count) =>
buildCellCard(
{
cell: { count, maxFrp: 10, newestAcqMs: 0 },
position: { x: 0, y: 0, z: 0 },
},
NOW,
).title;
const previous = getLocale();
try {
assert.equal(title(1), '1 FIRE');
setLocale('ar');
assert.deepEqual(
[2, 3, 14].map((count) => title(count).replace(/[\u2068\u2069]/g, '')),
['حريقان (2)', '3 حرائق', '14 حريقًا'],
);
setLocale('ru');
assert.equal(title(14), '14 ПОЖАРОВ');
} finally {
setLocale(previous);
}
});
// Owner field finding 2026-07-21: anchors must sit on the DEM once the shared
// ground floor is warm — and the cached per-fire position must re-anchor when
// the floor lands AFTER the first (cold, height-0) render. Distinct coords
+9 -8
View File
@@ -65,7 +65,8 @@ const KML_REMOVED_ELEMENTS = new Set([
/**
* An error whose message is meant for the person who chose the file. The
* message stays English; `template` and `values`, when given, let the UI show
* it in the active language.
* it in the active language. Numbers stay numbers in `values`: the UI formats
* them in that language's numerals.
*/
export class GeoImportError extends Error {
constructor(message, template = message, values = {}) {
@@ -259,7 +260,7 @@ function overLimitError({ over, features }) {
return new GeoImportError(
`That file creates more than ${MAX_IMPORT_ENTITIES.toLocaleString()} map objects (the limit).`,
msg('That file creates more than {limit} map objects (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
if (over === 'links')
return new GeoImportError(
@@ -267,13 +268,13 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} style definitions and image links after cleanup (the limit).',
),
{ limit: MAX_KML_LINKS.toLocaleString() },
{ limit: MAX_KML_LINKS },
);
if (over === 'features')
return new GeoImportError(
`That file has more than ${MAX_IMPORT_ENTITIES.toLocaleString()} features (the limit).`,
msg('That file has more than {limit} features (the limit).'),
{ limit: MAX_IMPORT_ENTITIES.toLocaleString() },
{ limit: MAX_IMPORT_ENTITIES },
);
const limit = MAX_IMPORT_POSITIONS.toLocaleString();
if (features)
@@ -282,12 +283,12 @@ function overLimitError({ over, features }) {
msg(
'That file has more than {limit} points (the limit) across {features} features.',
),
{ limit, features: features.toLocaleString() },
{ limit: MAX_IMPORT_POSITIONS, features },
);
return new GeoImportError(
`That file has more than ${limit} points (the limit).`,
msg('That file has more than {limit} points (the limit).'),
{ limit },
{ limit: MAX_IMPORT_POSITIONS },
);
}
@@ -1466,8 +1467,8 @@ export async function loadGeoFile(
`That file has ${entityCount.toLocaleString()} map objects; the limit is ${MAX_IMPORT_ENTITIES.toLocaleString()}.`,
msg('That file has {count} map objects; the limit is {limit}.'),
{
count: entityCount.toLocaleString(),
limit: MAX_IMPORT_ENTITIES.toLocaleString(),
count: entityCount,
limit: MAX_IMPORT_ENTITIES,
},
);
}
+7 -3
View File
@@ -9,9 +9,13 @@ import { unitLabel } from './ui/unitLabels.js';
import { localizePlaceName } from './ui/placeNames.js';
/**
* An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language.
* Chinese and Japanese have no case and keep the catalog's `13 km`.
* Scripts without letter case keep a space before the unit: `13 km` in
* Chinese and Japanese, `13 كم` in Arabic, which would otherwise run the
* digits into the word.
*/
const UNCASED_UNITS = /^(?:zh|ja|ar)(?:-|$)/;
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
export function hudQuantity(value, unit, digits = 0) {
const number = formatNumber(value, {
useGrouping: false,
@@ -19,7 +23,7 @@ export function hudQuantity(value, unit, digits = 0) {
maximumFractionDigits: digits,
});
const label = unitLabel(unit);
if (/^(zh|ja)(-|$)/.test(getLocale()))
if (UNCASED_UNITS.test(getLocale()))
return label ? `${number} ${label}` : number;
return `${number}${label.toUpperCase()}`;
}
+206
View File
@@ -0,0 +1,206 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
formatDate,
formatNumber,
getLocale,
keepCodesWithValues,
setLocale,
t,
tn,
} from './index.js';
import {
applyDocumentLanguage,
intlLocale,
LOCALE_STORAGE_KEY,
resolveLocale,
} 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 { createPresentation as createCctvPresentation } from '../layers/cctv/presentation.js';
import {
formatQuantity,
formatScaleLabel,
formatScaleRange,
} from '../ui/unitLabels.js';
test('Arabic resolves from URL, saved preference and browser language', () => {
assert.equal(
resolveLocale({
location: { search: '?lang=ar-EG' },
storage: { getItem: () => 'en' },
languages: ['en'],
}),
'ar',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: {
getItem: (key) => (key === LOCALE_STORAGE_KEY ? 'ar' : null),
},
languages: ['en'],
}),
'ar',
);
assert.equal(
resolveLocale({
location: { search: '' },
storage: { getItem: () => null },
languages: ['ar-SA', 'en'],
}),
'ar',
);
});
test('Arabic turns the page right-to-left; other languages stay left-to-right', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ar');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
applyDocumentLanguage(doc, 'zh-CN');
assert.deepEqual(doc.documentElement, { lang: 'zh-CN', dir: 'ltr' });
});
test('Arabic formats with Western digits whatever ICU defaults to', () => {
assert.equal(intlLocale('ar'), 'ar-u-nu-latn');
assert.equal(intlLocale('ru'), 'ru');
const previous = getLocale();
try {
setLocale('ar');
assert.equal(formatNumber(1234.5), '1,234.5');
assert.equal(
formatDate(Date.UTC(2026, 7, 26), {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}),
'26 أغسطس 2026',
);
assert.doesNotMatch(formatNumber(9876543210), /[٠-٩]/);
} finally {
setLocale(previous);
}
});
/** The text without the bidi isolates t() puts around values in RTL. */
const plain = (text) => text.replace(/[\u2068\u2069]/g, '');
test('Arabic uses all six plural forms and spaced HUD units', () => {
const previous = getLocale();
try {
setLocale('ar');
assert.deepEqual(
[0, 1, 2, 3, 11, 100].map((n) =>
plain(tn(n, '{count} SOURCE', '{count} SOURCES')),
),
[
'0 مصدر',
'مصدر واحد (1)',
'مصدران (2)',
'3 مصادر',
'11 مصدرًا',
'100 مصدر',
],
);
assert.equal(hudQuantity(2069, 'km', 1), '2069.0 كم');
// Isolated, so the number keeps its unit next to Latin words.
assert.equal(formatCockpitDistance(14000), '\u206814\u00a0كم\u2069');
assert.equal(
`Sentinel-2 · ${formatQuantity(30, 'm')}`,
'Sentinel-2 · \u206830\u00a0م\u2069',
);
assert.equal(
plain(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 })),
'القطاع 21.33ش 77.50غ',
);
assert.equal(READOUT_WORDS.daysAgo(9), 'قبل 9 أيام');
assert.equal(t('SAVE KEYS'), 'حفظ المفاتيح');
// Each value is isolated, so a time keeps its order inside Arabic text.
assert.equal(
t('Issued: {time}', { time: '10/28 00:00 UTC' }),
'صدر: \u206810/28 00:00 UTC\u2069',
);
// A Latin code stays beside its own value: HDG 68° · FOV 44°.
assert.equal(
keepCodesWithValues(
t('{city} · HDG {heading} · FOV {fov}', {
city: 'Austin',
heading: '68°',
fov: '44°',
}),
),
'\u2068Austin\u2069 · \u2068HDG \u206868°\u2069\u2069 · \u2068FOV \u206844°\u2069\u2069',
);
// A legend's open bound keeps its plus on the written side: 300+.
assert.deepEqual(['300+', '0.1'].map(formatScaleLabel), [
'\u2068300+\u2069',
'\u20680.1\u2069',
]);
// A Latin unit after the range keeps its order: °C, not C°.
assert.equal(
formatScaleRange('-40', '50+', '°C'),
`${formatScaleLabel('-40')} — \u206850+\u2069\u00a0\u2068°C\u2069`,
);
// Counted minutes agree with the number (3–10 plural, 11+ singular).
assert.deepEqual(
[3, 15].map((n) =>
formatNumber(n, {
style: 'unit',
unit: 'minute',
unitDisplay: 'long',
}),
),
['3 دقائق', '15 دقيقة'],
);
setLocale('ru');
assert.equal(keepCodesWithValues('HDG 68°'), 'HDG 68°');
assert.equal(formatCockpitDistance(14000), '14 КМ');
} finally {
setLocale(previous);
}
});
test('Arabic keeps each CCTV summary code beside its own value', () => {
const camera = {
id: 'cam-1',
city: 'Austin',
name: 'Congress Ave',
headingDeg: 68,
fovDeg: 44,
rangeM: 120,
feedType: 'image',
};
const active = { camera };
const presentation = createCctvPresentation({
state: {
_records: [active],
_healthById: new Map(),
_showProjection: true,
_coverageMode: 'sector',
},
parts: {
selection: { getActiveRecord: () => active },
model: {
sectorAreaKm2: () => 0.01,
currentViewContext: () => 'street:1',
},
geometry: { coverageNeighborCount: () => 0 },
calibration: { deriveCalBadge: () => 'auto' },
},
});
const previous = getLocale();
try {
setLocale('ar');
const parts = presentation.buildSummaryText().split(' · ');
// Each part is its own isolate: «HDG 68° · FOV 44°» cannot form one
// left-to-right run that hands 44° to the HDG side.
for (const part of parts) assert.match(part, /^\u2068[\s\S]*\u2069$/);
assert.ok(parts.includes('\u2068FOV \u206844\u2069°\u2069'), parts.join(' | '));
setLocale('en');
assert.doesNotMatch(presentation.buildSummaryText(), /[\u2068\u2069]/);
} finally {
setLocale(previous);
}
});
+7
View File
@@ -64,6 +64,9 @@ test('normalizeLocale folds regional variants and rejects unshipped tags', () =>
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('ar'), 'ar');
assert.equal(normalizeLocale('ar-EG'), 'ar');
assert.equal(normalizeLocale(' AR_sa '), 'ar');
assert.equal(normalizeLocale(undefined), null);
});
@@ -127,6 +130,10 @@ test('applyDocumentLanguage sets lang and dir', () => {
const doc = { documentElement: {} };
applyDocumentLanguage(doc, 'ru-RU');
assert.deepEqual(doc.documentElement, { lang: 'ru', dir: 'ltr' });
applyDocumentLanguage(doc, 'ar-EG');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
applyDocumentLanguage(doc, 'en');
assert.deepEqual(doc.documentElement, { lang: 'en', dir: 'ltr' });
});
test('t() returns English text in English and translates in Russian', () => {
+35 -4
View File
@@ -13,9 +13,12 @@
// `npm run i18n` keeps the catalogs in step with the code. Guide: docs/I18N.md.
import {
DEFAULT_LOCALE,
intlLocale,
isRtlLocale,
normalizeLocale,
writeStoredLocale,
} from './locale.js';
import ar from './locales/ar.json' with { type: 'json' };
import de from './locales/de.json' with { type: 'json' };
import es from './locales/es.json' with { type: 'json' };
import fr from './locales/fr.json' with { type: 'json' };
@@ -28,6 +31,7 @@ import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({
ar,
de,
es,
fr,
@@ -104,6 +108,29 @@ export function getLocale() {
return activeLocale;
}
/**
* A value inserted into right-to-left text as one unit: `30 م` next to
* `Sentinel-2` would otherwise lend its digits to the Latin run and leave the
* unit stranded. First-strong isolates (U+2068…U+2069) in RTL locales only.
*/
export function isolate(text) {
return isRtlLocale(getLocale()) ? `\u2068${text}\u2069` : String(text);
}
const CODE_BEFORE_VALUE =
/(?<![\p{L}\p{N}])([A-Z][A-Z0-9/]+) (\u2068[^\u2068\u2069]*\u2069)/gu;
/**
* A Latin code and the isolated value after it ("HDG 68\u00b0") as one unit in
* right-to-left text. Two codes in a row (HDG 68\u00b0 \u00b7 FOV 44\u00b0) otherwise form a
* single left-to-right run and the second code ends up beside the first
* value. Applies to t() output; a no-op outside RTL locales.
*/
export function keepCodesWithValues(text) {
if (!isRtlLocale(getLocale())) return text;
return String(text).replace(CODE_BEFORE_VALUE, '\u2068$1 $2\u2069');
}
/**
* Switch the active locale. Views re-render and onLocaleChange listeners
* word their text again; the application keeps <html lang>/<html dir> and
@@ -134,9 +161,13 @@ const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g;
* stays visible instead of silently vanishing. */
function interpolate(pattern, values) {
if (!values) return pattern;
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) =>
Object.hasOwn(values, name) ? String(values[name]) : literal,
);
// In a right-to-left page each value is isolated: a time, a code or an
// English name keeps its own order instead of mixing with the sentence.
return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => {
if (!Object.hasOwn(values, name)) return literal;
const value = String(values[name]);
return value === '' ? value : isolate(value);
});
}
/** A catalog string with something to show; an empty one counts as missing. */
@@ -228,7 +259,7 @@ function cachedFormatter(kind, Constructor, options) {
const cacheKey = `${kind}|${getLocale()}|${options ? JSON.stringify(options) : ''}`;
let formatter = formatterCache.get(cacheKey);
if (!formatter) {
formatter = new Constructor(getLocale(), options);
formatter = new Constructor(intlLocale(getLocale()), options);
formatterCache.set(cacheKey, formatter);
}
return formatter;
+22 -1
View File
@@ -10,6 +10,7 @@ export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze([
'en',
'ar',
'de',
'es',
'fr',
@@ -25,6 +26,7 @@ export const LOCALES = Object.freeze([
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
ar: 'العربية',
de: 'Deutsch',
es: 'Español',
fr: 'Français',
@@ -38,10 +40,29 @@ export const LOCALE_NAMES = Object.freeze({
});
/** Right-to-left locales. */
const RTL_LOCALES = Object.freeze([]);
const RTL_LOCALES = Object.freeze(['ar']);
/** Whether a shipped locale reads right to left. */
export function isRtlLocale(locale) {
return RTL_LOCALES.includes(normalizeLocale(locale));
}
/**
* Tags for Intl formatters where the locale code alone is not enough. Arabic
* keeps Western digits (0–9) like its catalog: ICU picks Arabic-Indic digits
* (٠–٩) for some Arabic regions and browsers, and a HUD that mixes both
* misreads.
*/
const INTL_TAGS = Object.freeze({ ar: 'ar-u-nu-latn' });
/** The tag to hand Intl.NumberFormat, DateTimeFormat and the like. */
export function intlLocale(locale) {
return INTL_TAGS[locale] ?? locale;
}
/**
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
* 'ar-EG' and 'ar-SA' → 'ar',
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -153,7 +153,6 @@
"NIGHT": "NACHT",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "BRAND",
"FIRES": "BRÄNDE",
"max {value} MW": "max. {value} MW",
"new {value}": "zuletzt vor {value}",
"just now": "gerade eben",
@@ -2937,5 +2936,7 @@
"Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten",
"Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen",
"Left panels": "Linke Panels",
"Right panels": "Rechte Panels"
"Right panels": "Rechte Panels",
"CONFIRMED": "BESTÄTIGT",
"PAD / RTLS": "STARTPLATZ / RTLS"
}
+2 -1
View File
@@ -662,7 +662,6 @@
"FIRE · {frp} MW": "INCENDIO · {frp} MW",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCENDIO",
"FIRES": "INCENDIOS",
"{value} conf": "confianza {value}",
"{value} ago": "hace {value}",
"max {value} MW": "máx {value} MW",
@@ -729,6 +728,8 @@
"awaiting usable AIS positions…": "esperando posiciones AIS utilizables…",
"awaiting first AIS message…": "esperando el primer mensaje AIS…",
"EST. DOWNRANGE": "DIST. ESTIMADA",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS",
"MISSION CONTROL · FIRST LAUNCH": "CENTRO DE MANDO · PRIMER INICIO",
"Choose your first view": "Elige tu primera vista",
"LIVE CONTACTS": "CONTACTOS EN VIVO",
+3 -2
View File
@@ -714,7 +714,6 @@
"NIGHT": "NUIT",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "FEU",
"FIRES": "FEUX",
"max {value} MW": "max {value} MW",
"new {value}": "dernier il y a {value}",
"{value}°N": "{value}°N",
@@ -3007,5 +3006,7 @@
"Edit anchors, data packs and shot details": "Modifier les ancres, les paquets de données et les détails des plans",
"Download this scene as JSON or an asset bundle": "Télécharger cette scène en JSON ou en archive de ressources",
"Left panels": "Panneaux de gauche",
"Right panels": "Panneaux de droite"
"Right panels": "Panneaux de droite",
"CONFIRMED": "CONFIRMÉE",
"PAD / RTLS": "PAS DE TIR / RTLS"
}
+3 -2
View File
@@ -714,7 +714,6 @@
"NIGHT": "NOTTE",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCENDIO",
"FIRES": "INCENDI",
"max {value} MW": "max {value} MW",
"new {value}": "ultimo: {value} fa",
"{value}°N": "{value}°N",
@@ -3007,5 +3006,7 @@
"Edit anchors, data packs and shot details": "Modifica ancore, pacchetti dati e dettagli degli scatti",
"Download this scene as JSON or an asset bundle": "Scarica questa scena come JSON o pacchetto di risorse",
"Left panels": "Pannelli a sinistra",
"Right panels": "Pannelli a destra"
"Right panels": "Pannelli a destra",
"CONFIRMED": "CONFERMATA",
"PAD / RTLS": "SITO DI LANCIO / RTLS"
}
+3 -2
View File
@@ -696,7 +696,6 @@
"NIGHT": "夜間",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "火災",
"FIRES": "火災",
"max {value} MW": "最大 {value} MW",
"new {value}": "新規 {value}",
"{value}°N": "{value}°N",
@@ -2867,5 +2866,7 @@
"Edit anchors, data packs and shot details": "アンカー、データパック、ショットの詳細を編集",
"Download this scene as JSON or an asset bundle": "このシーンをJSONまたはアセットバンドルとしてダウンロード",
"Left panels": "左パネル",
"Right panels": "右パネル"
"Right panels": "右パネル",
"CONFIRMED": "確認済み",
"PAD / RTLS": "射場 / RTLS"
}
+3 -2
View File
@@ -712,7 +712,6 @@
"NIGHT": "NOITE",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCÊNDIO",
"FIRES": "INCÊNDIOS",
"max {value} MW": "máx {value} MW",
"new {value}": "mais recente há {value}",
"{value}°N": "{value}°N",
@@ -3007,5 +3006,7 @@
"Edit anchors, data packs and shot details": "Editar âncoras, pacotes de dados e detalhes dos planos",
"Download this scene as JSON or an asset bundle": "Baixar esta cena como JSON ou pacote de recursos",
"Left panels": "Painéis à esquerda",
"Right panels": "Painéis à direita"
"Right panels": "Painéis à direita",
"CONFIRMED": "CONFIRMADA",
"PAD / RTLS": "PLATAFORMA / RTLS"
}
+2 -1
View File
@@ -135,7 +135,6 @@
"NIGHT": "НОЧЬ",
"▲ {frp} MW": "▲ {frp} МВт",
"FIRE": "ПОЖАР",
"FIRES": "ПОЖ.",
"max {value} MW": "макс. {value} МВт",
"new {value}": "последний {value}",
"just now": "только что",
@@ -217,6 +216,8 @@
"Pause replay": "Приостановить повтор",
"Cancel replay": "Отменить повтор",
"EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ",
"CONFIRMED": "ПОДТВЕРЖДЕНО",
"PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS",
"PLANNED · ": "ПЛАН · ",
"SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ",
"RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА",
+3 -2
View File
@@ -722,7 +722,6 @@
"NIGHT": "НІЧ",
"▲ {frp} MW": "▲ {frp} МВт",
"FIRE": "ПОЖЕЖА",
"FIRES": "ПОЖ.",
"max {value} MW": "макс. {value} МВт",
"new {value}": "останнє: {value} тому",
"{value}°N": "{value}° пн. ш.",
@@ -3077,5 +3076,7 @@
"Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів",
"Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів",
"Left panels": "Ліві панелі",
"Right panels": "Праві панелі"
"Right panels": "Праві панелі",
"CONFIRMED": "ПІДТВЕРДЖЕНО",
"PAD / RTLS": "МІСЦЕ ЗАПУСКУ / RTLS"
}
+2 -1
View File
@@ -695,7 +695,6 @@
"NIGHT": "夜间",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "火点",
"FIRES": "火点",
"max {value} MW": "最大 {value} MW",
"new {value}": "最新({value}前)",
"{value}°N": "{value}°N",
@@ -810,6 +809,8 @@
"Pause replay": "暂停回放",
"Cancel replay": "取消回放",
"EST. DOWNRANGE": "预估落点距离",
"CONFIRMED": "已确认",
"PAD / RTLS": "发射台 / RTLS",
"PLANNED · ": "计划 · ",
"SUPPLIED TRAJECTORY POINTS": "提供的轨迹点",
"RECONSTRUCTED ESTIMATE": "重建估算",
+2 -1
View File
@@ -690,7 +690,6 @@
"NIGHT": "夜間",
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "火點",
"FIRES": "火點",
"max {value} MW": "最大 {value} MW",
"new {value}": "最新({value}前)",
"{value}°N": "{value}°N",
@@ -805,6 +804,8 @@
"Pause replay": "暫停回放",
"Cancel replay": "取消回放",
"EST. DOWNRANGE": "預估落點距離",
"CONFIRMED": "已確認",
"PAD / RTLS": "發射臺 / RTLS",
"PLANNED · ": "計劃 · ",
"SUPPLIED TRAJECTORY POINTS": "提供的軌跡點",
"RECONSTRUCTED ESTIMATE": "重建估算",
+34
View File
@@ -1,4 +1,38 @@
{
"ar": [
"1090 MHz",
"978 MHz UAT",
"AIS: --",
"AIS: {name}",
"BRG {value}°",
"BRG —",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"FLIR",
"FOV",
"FOV {value}°",
"GEO",
"GOOGLE STREET VIEW",
"Google Street View",
"HDG",
"HDG {value}°",
"Ironbow",
"Landsat 8/9",
"NVG",
"REC",
"S2 GhostMaps",
"SQUAWK {code} · {status}",
"SRC {value}",
"Sentinel-2",
"WHOT/BHOT",
"dB",
"hPa",
"{source} HTTP {status}",
"▲ {frp} MW"
],
"de": [
"1090 MHz",
"978 MHz UAT",
+4 -1
View File
@@ -1,5 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { msg, t } from '../../i18n/index.js';
import { isolate, msg, t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
@@ -97,6 +97,8 @@ export function createPresentation({
const health = layerState._healthById.get(active.camera.id) || null;
const calBadge = parts.calibration.deriveCalBadge(active.camera);
// Each part is isolated: in right-to-left text a code keeps its own value
// (FOV 44°) instead of trading it with its neighbour.
return [
t('{city} CCTV', {
city: active.camera.city.toUpperCase(),
@@ -140,6 +142,7 @@ export function createPresentation({
: null,
]
.filter(Boolean)
.map(isolate)
.join(' · ');
}
+4 -1
View File
@@ -16,6 +16,7 @@ import {
} from './labels.js';
import {
formatUtcDayMonth,
isolate,
t,
tn,
msg,
@@ -25,9 +26,11 @@ import { formatQuantity } from '../../ui/unitLabels.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
// The clock is isolated (a no-op outside RTL): a no-break space between two
// numbers joins them into one run, which in Arabic split the date around it.
const utc = (value) =>
value
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${isolate(value.slice(11, 16))}\u00a0UTC`
: t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
+8 -4
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import { formatNumber, getLocale, isolate, t, tn } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -407,7 +407,6 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
@@ -419,7 +418,12 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
title: `${cell.count} ${noun}`,
// The count picks the plural form of the language ("حريقان (2)",
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
// other card titles.
title: tn(cell.count, '{n} fire', '{n} fires', {
n: cell.count,
}).toLocaleUpperCase(getLocale()),
details: [parts.join(' · ')],
selected: false,
priority: Number(cell.maxFrp) || 0,
@@ -513,7 +517,7 @@ export function createModel({
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
return `${isolate(latPart)} ${isolate(lonPart)}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
+16 -7
View File
@@ -1,11 +1,20 @@
import * as Cesium from 'cesium';
import { onLocaleChange, t } from '../../i18n/index.js';
import { formatNumber, isolate, onLocaleChange, t } from '../../i18n/index.js';
import {
MISSION_CLOSE_VIEW_RANGE_M,
MISSION_GLOBE_VIEW_RANGE_M,
} from './policy.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
// Landing endpoints carry an English accuracy code (the panel and the map
// entity compare it); only the text shown in the stage table is translated.
// The downrange estimate arrives already translated.
function accuracyLabel(accuracy) {
if (accuracy === 'CONFIRMED') return t('CONFIRMED');
if (accuracy === 'PAD / RTLS') return t('PAD / RTLS');
return accuracy;
}
export function createPanel({ state: layerState, services, parts, source }) {
/**
* Preserve the user's globe scale for roster previews while avoiding an
@@ -143,7 +152,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
? payload.operator
: null,
Number.isFinite(payload.massKg)
? `${payload.massKg.toLocaleString()} KG`
? `${formatNumber(payload.massKg)} KG`
: null,
]
.filter(Boolean)
@@ -165,10 +174,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
? launch.launchSite
: t('UNAVAILABLE'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
? `${isolate(`${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)}`)} · ${accuracyLabel(endpoint.accuracy)}`
: stage.downrangeKm > 0
? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
value: formatNumber(stage.downrangeKm),
})
: t('POSITION UNAVAILABLE');
const detail = [
@@ -325,21 +334,21 @@ export function createPanel({ state: layerState, services, parts, source }) {
layerState._missionTelemetry = {
distance: Number.isFinite(altitudeM)
? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
value: formatNumber(Math.max(0, altitudeM / 1000), {
maximumFractionDigits: 0,
}),
})
: null,
speed: Number.isFinite(speedMps)
? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
value: formatNumber(speedMps / 1000, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})
: null,
speedTitle: Number.isFinite(speedMps)
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h`
: null,
};
if (republish) publishPanel();
+2 -1
View File
@@ -365,8 +365,9 @@ export function createStreetLevelLayer({
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
// No-break spaces keep a provider name (Google Street View) on one line.
return t('zoom in to a street to open {name}', {
name: translateKnown(entry.def.name),
name: translateKnown(entry.def.name).replace(/ /g, '\u00a0'),
});
return stats.hint || '';
}
+10 -7
View File
@@ -7,6 +7,9 @@
*/
/** Shared visual tokens used by every world-overlay source. */
// Map-card fonts: JetBrains Mono has no Arabic glyphs and the generic
// monospace fallback is a fixed-pitch Arabic face that stretches every word;
// Arabic text falls through to Noto Sans Arabic (loaded with the page fonts).
export const WORLD_OVERLAY_STYLE = Object.freeze({
background: 'rgba(4, 12, 16, 0.82)',
selectedBackground: 'rgba(5, 18, 24, 0.94)',
@@ -16,13 +19,13 @@ export const WORLD_OVERLAY_STYLE = Object.freeze({
detail: 'rgba(147, 161, 173, 0.92)',
leader: 'rgba(147, 213, 228, 0.58)',
accent: '#6be8ff',
fontLabel: '500 10px "JetBrains Mono", monospace',
fontTrack: '600 10px "JetBrains Mono", monospace',
fontTitle: '600 12px "JetBrains Mono", monospace',
fontDetail: '500 10.5px "JetBrains Mono", monospace',
fontSelected: '600 13px "JetBrains Mono", monospace',
fontTrackedTitle: '600 13px "JetBrains Mono", monospace',
fontTrackedDetail: '500 11px "JetBrains Mono", monospace',
fontLabel: '500 10px "JetBrains Mono", "Noto Sans Arabic", monospace',
fontTrack: '600 10px "JetBrains Mono", "Noto Sans Arabic", monospace',
fontTitle: '600 12px "JetBrains Mono", "Noto Sans Arabic", monospace',
fontDetail: '500 10.5px "JetBrains Mono", "Noto Sans Arabic", monospace',
fontSelected: '600 13px "JetBrains Mono", "Noto Sans Arabic", monospace',
fontTrackedTitle: '600 13px "JetBrains Mono", "Noto Sans Arabic", monospace',
fontTrackedDetail: '500 11px "JetBrains Mono", "Noto Sans Arabic", monospace',
radius: 4,
anchorDotRadius: 3.2,
anchorDotStrokeWidth: 1,
+4 -3
View File
@@ -1,4 +1,4 @@
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -78,13 +78,14 @@ function calValueText(field, value) {
useGrouping: false,
...options,
});
return `${t(field.label)} ${t('{value}m', { value: metres })}`;
return `${t(field.label)} ${isolate(t('{value}m', { value: metres }))}`;
}
const number = formatNumber(Number(value), {
useGrouping: false,
...options,
});
return `${t(field.label)} ${number}${field.unit}`;
// Isolated so a minus sign stays on its number in right-to-left text.
return `${t(field.label)} ${isolate(`${number}${field.unit}`)}`;
}
export function _activeCctvCameraId() {
+15 -13
View File
@@ -1,4 +1,4 @@
import { getLocale, msg, t, tn } from '../i18n/index.js';
import { getLocale, keepCodesWithValues, msg, t, tn } from '../i18n/index.js';
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
@@ -102,18 +102,20 @@ function metaText(state, enabled, activeCamera, catalogCount) {
? _calBadgeLabel(activeCamera.calBadge)
: '';
const projLabel = state?.showProjection !== false ? t('MONITOR') : t('OFF');
return t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
},
return keepCodesWithValues(
t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
},
),
);
}
if (catalogCount > 0)
+8 -7
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -95,18 +95,19 @@ export function formatCockpitCoordinate(value, positive, negative) {
maximumFractionDigits: 3,
});
const code = value >= 0 ? positive : negative;
// Not «30,203°С»: after a degree sign «С» reads as Celsius.
// Not «30,203°С»: after a degree sign «С» reads as Celsius. Isolated, so
// an Arabic hemisphere letter does not pull the next number into its run.
switch (code) {
case 'N':
return t('{value}°N', { value: amount });
return isolate(t('{value}°N', { value: amount }));
case 'S':
return t('{value}°S', { value: amount });
return isolate(t('{value}°S', { value: amount }));
case 'E':
return t('{value}°E', { value: amount });
return isolate(t('{value}°E', { value: amount }));
case 'W':
return t('{value}°W', { value: amount });
return isolate(t('{value}°W', { value: amount }));
default:
return `${amount}°${compassPointLabel(code)}`;
return isolate(`${amount}°${compassPointLabel(code)}`);
}
}
+7 -4
View File
@@ -555,7 +555,7 @@ test('Cyber side panels share one width and one framed surface material', () =>
);
assert.match(
cyberStyles,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-inline-end: 8px;/,
);
assert.match(
cyberStyles,
@@ -779,16 +779,19 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: calc\(54px \+ var\(--panel-rail-clearance\)\);/);
assert.match(
cyberStyles,
/inset-inline-start: calc\(54px \+ var\(--panel-rail-clearance\)\);/,
);
assert.match(cyberStyles, /right: calc\(51px \+ var\(--panel-rail-clearance\)\);/);
// Each side's edge line shows only beside an open panel.
assert.match(
cyberStyles,
/#left-panel-stack:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-left-color:/,
/#left-panel-stack:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-start-color:/,
);
assert.match(
cyberStyles,
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-right-color:/,
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
+14 -1
View File
@@ -10,6 +10,16 @@ import { flyToDataSource } from './flyToDataSource.js';
// One color per file, in import order, from the whiteboard palette.
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/** An import error's values, with its counts and limits in local numerals. */
function localizedValues(values) {
return Object.fromEntries(
Object.entries(values || {}).map(([name, value]) => [
name,
typeof value === 'number' ? formatNumber(value) : value,
]),
);
}
/**
* Run the Import control.
* @param {{viewer: object, load?: Function, document?: Document,
@@ -256,7 +266,10 @@ export function initGeoFileImport({
ok: false,
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? t(error.template || error.message, error.values)
? t(
error.template || error.message,
localizedValues(error.values),
)
: t('That file could not be read.'),
});
continue;
+18
View File
@@ -606,3 +606,21 @@ test('the map-object count in a row title keeps digit grouping', async () => {
assert.match(row.title, /12,345 map objects/);
t.tool.destroy();
});
test('a refusal shows its counts in the language’s numerals, not the browser’s', async () => {
const t = setup({
load: async () => {
throw new GeoImportError(
'That file has 60,000 map objects; the limit is 50,000.',
'That file has {count} map objects; the limit is {limit}.',
{ count: 60000, limit: 50000 },
);
},
});
const [out] = await t.tool.importFiles([file('huge.kml')]);
assert.equal(
out.error,
'That file has 60,000 map objects; the limit is 50,000.',
);
t.tool.destroy();
});
+65 -3
View File
@@ -43,7 +43,20 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'uk', 'de', 'en', 'es', 'fr', 'it', 'pt', 'zh-CN', 'zh-Hant', 'ja'],
[
'ru',
'uk',
'de',
'en',
'es',
'fr',
'it',
'pt',
'ar',
'zh-CN',
'zh-Hant',
'ja',
],
);
assert.deepEqual(
codes(
@@ -53,7 +66,20 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'de', 'en', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
[
'es',
'de',
'en',
'fr',
'it',
'pt',
'ru',
'uk',
'ar',
'zh-CN',
'zh-Hant',
'ja',
],
);
assert.deepEqual(
codes(
@@ -63,7 +89,43 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'de', 'es', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
[
'en',
'de',
'es',
'fr',
'it',
'pt',
'ru',
'uk',
'ar',
'zh-CN',
'zh-Hant',
'ja',
],
);
assert.deepEqual(
codes(
buildLocaleOptions({
locales: LOCALES,
current: 'ar',
nativeNames: LOCALE_NAMES,
}),
),
[
'ar',
'de',
'en',
'es',
'fr',
'it',
'pt',
'ru',
'uk',
'zh-CN',
'zh-Hant',
'ja',
],
);
const rows = buildLocaleOptions({
locales: MANY,
+2 -10
View File
@@ -1,7 +1,7 @@
<script setup>
import { onBeforeUnmount } from 'vue';
import { t } from '../../i18n/index.js';
import { formatScaleLabel, unitLabel } from '../unitLabels.js';
import { formatScaleRange } from '../unitLabels.js';
import RailCardSetting from './RailCardSetting.vue';
import RailChips from './RailChips.vue';
import RailRowList from './RailRowList.vue';
@@ -31,14 +31,6 @@ const rootTag = (block) =>
block.type === 'list' ? 'ol' : block.type === 'actions' ? 'footer' : 'div';
const lines = (block) => block.lines || [{ id: block.id, text: block.text }];
// "960 — 1050+ гПа": the unit stays with the last number. Units a source
// supplied as data are shown as given.
function scaleText(low, high, units, verbatim) {
const unit = verbatim ? units || '' : unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range} ${unit}` : range;
}
function legendView(legend) {
const colors = (legend?.colors || []).filter((color) =>
/^#(?:[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color),
@@ -64,7 +56,7 @@ function legendView(legend) {
? `left: ${(legend.zeroIndex / (colors.length - 1)) * 100}%`
: undefined,
scale: visible
? scaleText(
? formatScaleRange(
legend.labels[0],
legend.labels.at(-1),
legend.units,
+148
View File
@@ -0,0 +1,148 @@
// Right-to-left layout pins: chrome that mirrors in Arabic must mirror on
// both sides of a pair, or one half lands on the other or off the screen.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const sheet = (name) =>
readFileSync(new URL(`./styles/${name}`, import.meta.url), 'utf8');
/** The declarations of the first rule whose selector is exactly `selector`. */
function ruleBody(css, selector) {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*\\{([^}]*)\\}`).exec(
css,
);
assert.ok(match, `no rule for ${selector}`);
return match[1];
}
test('the dock trays open from their own wing in right-to-left', () => {
const rtl = sheet('rtl.css');
const location = ruleBody(
rtl,
"html[dir='rtl'] #command-dock #location-bar .dock-popover-content",
);
assert.match(
location,
/right: calc\(\(var\(--dock-unified-width\) - var\(--dock-location-open\)\) \/ 2\);/,
);
assert.match(location, /left: auto;/);
assert.match(location, /transform-origin: bottom right;/);
const presets = ruleBody(
rtl,
"html[dir='rtl'] #command-dock #control-panel .dock-popover-content",
);
assert.match(
presets,
/left: calc\(\(var\(--dock-unified-width\) - var\(--dock-presets-open\)\) \/ 2\);/,
);
assert.match(presets, /right: auto;/);
assert.match(presets, /transform-origin: bottom left;/);
// The narrow band re-centres both trays on the mobile tray width.
const narrow = /@media \(max-width: 900px\) \{([\s\S]*?)\n\}/.exec(rtl);
assert.ok(narrow, 'no max-width:900px block in rtl.css');
assert.match(
narrow[1],
/#location-bar \.dock-popover-content \{\s*right: calc\(\s*\(var\(--dock-unified-width\) - var\(--dock-popover-mobile-width\)\) \/ 2\s*\);/,
);
assert.match(
narrow[1],
/#control-panel \.dock-popover-content \{\s*left: calc\(\s*\(var\(--dock-unified-width\) - var\(--dock-popover-mobile-width\)\) \/ 2\s*\);/,
);
});
test('the Cyber cockpit keeps Data Layers on the left edge in every language', () => {
const body = ruleBody(
sheet('cyber.css'),
":root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack",
);
// inset-inline-start would become right: 30px in Arabic and beat
// rtl.css's reset, landing the stack on the utility controls.
assert.doesNotMatch(body, /inset-inline-start/);
assert.match(body, /left: 30px;/);
assert.match(body, /right: auto;/);
});
test('the panel rails stay on the outer side of their stacks in right-to-left', () => {
const layers = sheet('layers.css');
// Each rail is placed against its stack's outer edge; a physical side
// would put it over the panels once the stacks swap sides.
const left = ruleBody(layers, '.gev-panel-rail--left');
assert.match(left, /inset-inline-end: calc\(100% \+ 6px\);/);
assert.doesNotMatch(left, /(?:^|\s)(?:left|right):/);
const right = ruleBody(layers, '.gev-panel-rail--right');
assert.match(right, /inset-inline-start: calc\(100% \+ 6px\);/);
assert.doesNotMatch(right, /(?:^|\s)(?:left|right):/);
// Their tips open toward the middle of the screen.
const kit = sheet('kit.css');
assert.match(
ruleBody(kit, '.ui-tooltip__tip--right'),
/inset-inline-start: calc\(100% \+ 10px\);/,
);
assert.match(
ruleBody(kit, '.ui-tooltip__tip--left'),
/inset-inline-end: calc\(100% \+ 10px\);/,
);
// Cyber's right stack keeps its rail clearance on the left edge.
assert.match(
sheet('rtl.css'),
/html\[dir='rtl'\]\[data-ui-theme='cyber'\] #right-context-rail \{\s*right: auto;\s*left: calc\(51px \+ var\(--panel-rail-clearance\)\);/,
);
});
test('phone top-bar actions mirror with the logo', () => {
const mobile = sheet('mobile-shell.css');
const actions = ruleBody(mobile, 'body.mobile-shell #top-center-actions');
assert.match(actions, /inset-inline-start: auto;/);
assert.match(actions, /inset-inline-end: 12px;/);
assert.doesNotMatch(actions, /(?:^|\s)right:/);
const corner = ruleBody(mobile, 'body.mobile-shell #corner-actions');
assert.match(corner, /inset-inline-end: 12px;/);
assert.doesNotMatch(corner, /(?:^|\s)right:/);
});
test('the Space Missions counter reads as a number pair in right-to-left', () => {
const counters =
/html\[dir='rtl'\]\s*:is\(([^)]*\[data-role='beat-index'\][^)]*)\)\s*\{([^}]*)\}/.exec(
sheet('rtl.css'),
);
assert.ok(counters, 'no left-to-right counter rule in rtl.css');
assert.match(counters[1], /\[data-mission-index\]/);
assert.match(counters[2], /direction: ltr;/);
});
test('select chevrons and legend ranges follow the field and the ramp in right-to-left', () => {
const field = sheet('form-field.css');
assert.match(
ruleBody(field, 'select.gev-field'),
/padding-inline-end: 22px;/,
);
assert.match(
ruleBody(field, 'select.gev-field--compact'),
/padding-inline-end: 18px;/,
);
// Selects that set their own padding keep the chevron's room at the end.
for (const [name, selector, inline] of [
['radio.css', '#radio-filter', '8px 24px'],
['radio.css', '.sdr-gain-row select', '7px 22px'],
['voice-card.css', '#gev-voice-timeout-select', '0.45rem 22px'],
]) {
const body = ruleBody(sheet(name), selector);
assert.match(body, new RegExp(`padding-inline: ${inline};`));
assert.doesNotMatch(body, /(?:^|\s)padding(?:-right)?:/);
}
const rtl = sheet('rtl.css');
assert.match(
ruleBody(rtl, "html[dir='rtl'] select.gev-field"),
/background-position: left 8px center;/,
);
assert.match(
ruleBody(rtl, "html[dir='rtl'] select.gev-field--compact"),
/background-position: left 6px center;/,
);
assert.match(
ruleBody(rtl, "html[dir='rtl'] .rail-card-scale"),
/direction: ltr;/,
);
});
+12 -2
View File
@@ -1,4 +1,11 @@
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import {
formatNumber,
isolate,
msg,
t,
tn,
translateKnown,
} from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -174,7 +181,10 @@ function presentViewer(state) {
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
// A provider address mixes Latin and the page language ("602 Congress Ave,
// أوستن"); each part is isolated so a house number stays with its street
// in right-to-left text. A no-op in left-to-right languages.
if (street.title) left.push(street.title.split(', ').map(isolate).join(', '));
if (street.creator)
left.push(t('Image by {creator}', { creator: street.creator }));
if (street.license) left.push(street.license);
+24
View File
@@ -5,6 +5,7 @@ import {
sinceStopIndex,
} from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
import { getLocale, setLocale } from '../i18n/index.js';
const provider = providerSnapshot;
@@ -321,6 +322,29 @@ test('a Street View caption names the place, dates it by month, and offers no FI
assert.equal(view.viewer.renderModes, false);
});
test('in Arabic each part of a provider address keeps its own order', () => {
const previous = getLocale();
setLocale('ar');
try {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
imageId: 'pano-1',
title: '602 Congress Ave, أوستن, تكساس',
},
}),
);
assert.equal(
view.viewer.captionLeft,
'\u2068602 Congress Ave\u2069, \u2068أوستن\u2069, \u2068تكساس\u2069',
);
} finally {
setLocale(previous);
}
});
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
const view = presentStreetLevelPanel(
snapshot({
+10 -9
View File
@@ -1093,7 +1093,7 @@ body.cockpit-mode #view-switcher button {
gap: 3px 8px;
min-width: 0;
padding: 6px 9px;
border-left: 1px solid
border-inline-start: 1px solid
color-mix(in srgb, var(--cockpit-accent) 40%, transparent);
background: rgba(0, 7, 12, 0.34);
}
@@ -1119,7 +1119,7 @@ body.cockpit-mode #view-switcher button {
white-space: nowrap;
}
.cockpit-context-cohorts > div.unknown {
border-left-color: rgba(213, 168, 99, 0.58);
border-inline-start-color: rgba(213, 168, 99, 0.58);
}
.cockpit-context-cohorts > div.unknown strong {
color: #d5a863;
@@ -1611,8 +1611,9 @@ html:not([lang='en']) #cockpit-brief-auto {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 8px;
padding: 7px 7px 7px 9px;
border-left: 1px solid rgba(244, 251, 255, 0.2);
padding-block: 7px;
padding-inline: 9px 7px;
border-inline-start: 1px solid rgba(244, 251, 255, 0.2);
background: rgba(0, 7, 12, 0.3);
}
.cockpit-signal-list li.actionable {
@@ -1622,7 +1623,7 @@ html:not([lang='en']) #cockpit-brief-auto {
}
.cockpit-signal-list li.actionable:hover,
.cockpit-signal-list li.actionable:focus-within {
border-left-color: var(--cockpit-accent);
border-inline-start-color: var(--cockpit-accent);
background: color-mix(
in srgb,
var(--cockpit-accent) 10%,
@@ -1631,16 +1632,16 @@ html:not([lang='en']) #cockpit-brief-auto {
}
.cockpit-signal-list li.track,
.cockpit-signal-list li.current {
border-left-color: var(--cockpit-accent);
border-inline-start-color: var(--cockpit-accent);
}
.cockpit-signal-list li.nearby {
border-left-color: #fff;
border-inline-start-color: #fff;
}
.cockpit-signal-list li.military {
border-left-color: #d5a863;
border-inline-start-color: #d5a863;
}
.cockpit-signal-list li.warning {
border-left-color: #d5a863;
border-inline-start-color: #d5a863;
}
.cockpit-signal-list time {
color: rgba(244, 251, 255, 0.38);
+13 -10
View File
@@ -245,7 +245,7 @@
.street-level-panel-inner,
.global-context-panel-inner
) {
padding-right: 8px;
padding-inline-end: 8px;
}
/* Collapsed right-rail panels otherwise inherit different legacy gutters:
@@ -1658,23 +1658,23 @@
--panel-stack-top-pinned: 1;
top: var(--cyber-left-rail-top) !important;
bottom: calc(2vh + 13rem);
left: calc(54px + var(--panel-rail-clearance));
inset-inline-start: calc(54px + var(--panel-rail-clearance));
width: 300px;
padding-left: 10px;
border-left: 1px solid transparent;
padding-inline-start: 10px;
border-inline-start: 1px solid transparent;
}
/* An edge line marks each side's open panel, mirrored left and right. */
:root[data-ui-theme='cyber']
body:not(.cockpit-mode)
#left-panel-stack:has(> [data-panel-id]:not(.collapsed, [hidden])) {
border-left-color: rgba(196, 84, 84, 0.72);
border-inline-start-color: rgba(196, 84, 84, 0.72);
}
:root[data-ui-theme='cyber']
body:not(.cockpit-mode)
#right-context-rail:has(> [data-panel-id]:not(.collapsed, [hidden])) {
border-right-color: rgba(196, 84, 84, 0.72);
border-inline-end-color: rgba(196, 84, 84, 0.72);
}
:root[data-ui-theme='cyber']
@@ -1756,8 +1756,8 @@
/* The edge line and its gap keep the panels where they were. */
right: calc(51px + var(--panel-rail-clearance));
width: 323px;
padding-right: 10px;
border-right: 1px solid transparent;
padding-inline-end: 10px;
border-inline-end: 1px solid transparent;
}
/* The map rails now use one visible expanded width. Keeping the rail itself
@@ -2749,10 +2749,13 @@
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack {
top: clamp(164px, 19vh, 204px) !important;
bottom: auto;
/* The cockpit keeps its physical sides in every language (rtl.css): the
utility controls hold the right edge. */
right: auto;
left: 30px;
width: var(--cyber-panel-width);
padding-left: 0;
border-left: 0;
padding-inline-start: 0;
border-inline-start: 0;
}
:root[data-ui-theme='cyber'] body.cockpit-mode #left-panel-stack > #data-panel {
+1 -1
View File
@@ -124,7 +124,7 @@
min-height: 3.55rem;
padding: 0.6rem 0.7rem;
color: rgba(225, 244, 248, 0.92);
text-align: left;
text-align: start;
background: rgba(29, 69, 81, 0.18);
border: 1px solid rgba(100, 200, 220, 0.18);
border-radius: 0.42rem;
+2 -2
View File
@@ -19,7 +19,7 @@
select.gev-field {
-webkit-appearance: none;
appearance: none;
padding-right: 22px;
padding-inline-end: 22px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='rgba(255,255,255,0.62)' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px center;
@@ -45,7 +45,7 @@ select.gev-field {
}
select.gev-field--compact {
padding-right: 18px;
padding-inline-end: 18px;
background-position: right 6px center;
}
+4 -4
View File
@@ -503,7 +503,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
#title-bar {
position: fixed;
top: 32px;
left: 36px;
inset-inline-start: 36px;
z-index: 100;
pointer-events: none;
user-select: none;
@@ -512,7 +512,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
#title-bar .title-glow {
position: absolute;
top: -20px;
left: -20px;
inset-inline-start: -20px;
width: 200px;
height: 80px;
background: radial-gradient(ellipse, var(--accent-dim) 0%, transparent 70%);
@@ -637,11 +637,11 @@ body.cockpit-mode #cockpit-cloud-effects.active {
#style-indicator {
position: fixed;
top: 32px;
right: 36px;
inset-inline-end: 36px;
z-index: 100;
pointer-events: none;
user-select: none;
text-align: right;
text-align: end;
}
#style-indicator .indicator-label {
+5 -3
View File
@@ -60,11 +60,13 @@
pointer-events: none;
transform: translateY(-50%);
}
/* Sides are for left-to-right; a right-to-left page mirrors them with the
rails, so a tip still opens toward the middle of the screen. */
.ui-tooltip__tip--right {
left: calc(100% + 10px);
inset-inline-start: calc(100% + 10px);
}
.ui-tooltip__tip--left {
right: calc(100% + 10px);
inset-inline-end: calc(100% + 10px);
}
.ui-rail {
@@ -117,7 +119,7 @@
.ui-rail__badge {
position: absolute;
top: 1px;
right: 1px;
inset-inline-end: 1px;
min-width: 14px;
padding: 0 3px;
border-radius: 7px;
+8 -8
View File
@@ -3,7 +3,7 @@
position: fixed;
top: var(--left-stack-safe-top, var(--left-stack-top));
bottom: var(--left-stack-safe-bottom, 4vh);
left: var(--left-stack-x);
inset-inline-start: var(--left-stack-x);
z-index: 100;
width: 360px;
display: flex;
@@ -177,11 +177,11 @@
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
margin-inline-start: auto;
}
#right-context-rail > #pp-toggles.collapsed {
width: var(--right-display-collapsed-width);
margin-left: auto;
margin-inline-start: auto;
}
#right-context-rail > #pp-toggles.collapsed .pp-header-row {
width: 100%;
@@ -220,7 +220,7 @@
width: var(--right-context-collapsed-width);
/* Keep the compact Context tab on the rail edge rather than leaving it
stranded at the rail's expanded-width origin. */
margin-left: auto;
margin-inline-start: auto;
}
#right-context-rail
#global-context-panel.collapsed
@@ -256,10 +256,10 @@
transition: top var(--transition-fast);
}
.gev-panel-rail--left {
right: calc(100% + 6px);
inset-inline-end: calc(100% + 6px);
}
.gev-panel-rail--right {
left: calc(100% + 6px);
inset-inline-start: calc(100% + 6px);
}
body.mobile-shell #left-panel-stack > .gev-panel-rail,
body.mobile-shell #right-context-rail > .gev-panel-rail,
@@ -396,7 +396,7 @@ body.recording-mode .gev-panel-rail {
gap: 10px;
padding: 1px 3px 7px;
border-bottom: 1px solid rgba(34, 230, 230, 0.14);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
}
#space-mission-panel-host .space-mission-roster-header > div {
display: grid;
@@ -440,7 +440,7 @@ body.recording-mode .gev-panel-rail {
border-radius: 4px;
color: #d8ffff;
background: rgba(42, 113, 171, 0.045);
font-family: 'JetBrains Mono', monospace;
font-family: var(--font-mono);
text-align: left;
cursor: pointer;
transition:
+5 -4
View File
@@ -84,10 +84,10 @@ body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
background: rgba(0, 0, 0, 0.18);
}
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
/* ── Top bar: logo at the start, globe actions at the end ──────────────── */
body.mobile-shell #title-bar {
top: calc(var(--mobile-top-inset) + 10px);
left: 14px;
inset-inline-start: 14px;
}
body.mobile-shell #title-bar .title-logo {
@@ -101,7 +101,8 @@ body.mobile-shell #title-bar .subtitle {
body.mobile-shell #top-center-actions {
top: calc(var(--mobile-top-inset) + 10px);
right: 12px;
inset-inline-start: auto;
inset-inline-end: 12px;
}
/* ── HUD: one compact readout under the top bar ────────────────────────── */
@@ -400,7 +401,7 @@ body.mobile-shell:not(.cockpit-mode) #view-switcher {
body.mobile-shell #corner-actions {
top: calc(var(--mobile-top-inset) + 60px);
right: 12px;
inset-inline-end: 12px;
bottom: auto;
}
+4 -2
View File
@@ -49,7 +49,8 @@
#radio-filter {
/* Radio owns its compact density and arrow space; shared fields paint the
palette and disabled state, including this intentional theme exception. */
padding: 7px 24px 7px 8px;
padding-block: 7px;
padding-inline: 8px 24px;
border-radius: 5px;
font: 9px/1.2 var(--font-mono);
--gev-field-border-color: rgba(65, 220, 231, 0.28);
@@ -1128,7 +1129,8 @@ html:not([lang='en']) .data-row-list-text {
.sdr-gain-row select {
flex: 1;
min-width: 0;
padding: 5px 22px 5px 7px;
padding-block: 5px;
padding-inline: 7px 22px;
}
.sdr-adsb-stats {
display: grid;
+3 -3
View File
@@ -25,7 +25,7 @@
#title-bar {
top: 20px;
left: 20px;
inset-inline-start: 20px;
}
#top-center-actions {
@@ -55,7 +55,7 @@
#title-bar .frame-rate-readout {
position: fixed;
top: 52px;
left: 20px;
inset-inline-start: 20px;
margin: 0;
font-size: 10px;
line-height: 12px;
@@ -245,7 +245,7 @@
/* 16px keeps the Radio broadcast waves (15px above the logo) on-screen. */
#title-bar {
top: 16px;
left: 16px;
inset-inline-start: 16px;
}
#title-bar h1 > span:last-child,
+222
View File
@@ -0,0 +1,222 @@
/* Right-to-left languages (Arabic). The chrome mirrors through logical
properties in the component sheets (inset-inline-*, margin-inline-*); this
sheet holds only what logical properties cannot express. */
/* Arabic letters join: letter-spacing tears every word into separate glyphs.
Latin marked translate="no" and the intel HUD's Latin telemetry keep their
tracking; the HUD's translated lines are released below. Unlike cjk.css, a
container that merely holds a translate="no" span is not spared: its own
Arabic text would come apart. */
html:lang(ar)
body
:not(#intel-hud, #intel-hud *, [translate='no'], [translate='no'] *) {
letter-spacing: 0 !important;
}
/* The intel HUD is an instrument overlay: corners, brackets, coordinates and
sensor codes keep the English geometry. Its translated lines still read
right to left inside that frame. */
html[dir='rtl'] #intel-hud {
direction: ltr;
}
html[dir='rtl'] #intel-hud :is(.hud-mode, .hud-summary-label, .hud-summary) {
unicode-bidi: plaintext;
text-align: left;
letter-spacing: 0 !important;
}
/* Font stacks: JetBrains Mono and Inter have no Arabic glyphs, and the
generic monospace fallback is a fixed-pitch Arabic face that spaces every
letter apart. Noto Sans Arabic (loaded with the other Google fonts; its
files download only when the page shows Arabic) leads the mono stack, so
the spaces between Arabic words come from it too and not from the
fixed-pitch Latin face. The intel and cockpit HUDs keep JetBrains Mono for
their Latin telemetry and column-aligned digits. */
html:lang(ar) {
--font-mono:
'Noto Sans Arabic', 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Geeza Pro',
Tahoma, monospace;
--font-sans:
'Inter', 'Noto Sans Arabic', -apple-system, BlinkMacSystemFont, 'Geeza Pro',
Tahoma, sans-serif;
}
/* The Global Context lines and their buttons set ui-monospace through the
`font` shorthand; route them through the Arabic-aware stack too. */
html:lang(ar)
:is(
#military-awareness-panel,
.military-awareness-controls button,
.military-awareness-standby button,
.military-awareness-compass-label,
.military-awareness-compass-heading,
.military-awareness-direction-label,
.tr3b-toggle
) {
font-family: var(--font-mono);
}
html:lang(ar) :is(#intel-hud, #cockpit-hud) {
--font-mono:
'JetBrains Mono', 'SF Mono', 'Fira Code', 'Noto Sans Arabic', 'Geeza Pro',
Tahoma, monospace;
}
/* The Display/Context rail moves to the left edge. It keeps a physical
`right` in layers.css because the attribution-clearance model
(creditAttribution.test.mjs) reads that rule; the swap only applies above
the 720px band, where the rail is not edge-to-edge. */
@media (min-width: 721px) {
html[dir='rtl'] #right-context-rail {
right: auto;
left: var(--right-rail-x);
}
}
@media (min-width: 961px) and (min-height: 650px) {
html[dir='rtl'][data-ui-theme='cyber'] #right-context-rail {
right: auto;
left: calc(51px + var(--panel-rail-clearance));
}
}
/* The dock's wings swap sides (Location on the right, Visual presets on the
left), so each slide-up tray is anchored from the wing's outer edge and
centred on the whole dock from there, as it is in left-to-right. */
html[dir='rtl'] #command-dock #location-bar .dock-popover-content {
right: calc((var(--dock-unified-width) - var(--dock-location-open)) / 2);
left: auto;
transform-origin: bottom right;
}
html[dir='rtl'] #command-dock #control-panel .dock-popover-content {
right: auto;
left: calc((var(--dock-unified-width) - var(--dock-presets-open)) / 2);
transform-origin: bottom left;
}
@media (max-width: 900px) {
html[dir='rtl'] #command-dock #location-bar .dock-popover-content {
right: calc(
(var(--dock-unified-width) - var(--dock-popover-mobile-width)) / 2
);
}
html[dir='rtl'] #command-dock #control-panel .dock-popover-content {
left: calc(
(var(--dock-unified-width) - var(--dock-popover-mobile-width)) / 2
);
}
}
/* The tray pin sits on the tray's top end corner: top-left here. */
html[dir='rtl'] #command-dock :is(#location-bar, #control-panel) .dock-pin-btn {
right: auto;
left: -1rem;
}
/* The right rail sits on the left: its expand/collapse arrows point the
other way, and the start window's "go" arrows point along the reading
direction. */
html[dir='rtl'] #right-context-rail .panel-collapse-btn,
html[dir='rtl'] .first-run-arrow {
transform: scaleX(-1);
}
html[dir='rtl'] .first-run-choices button:hover .first-run-arrow,
html[dir='rtl'] .first-run-choices button:focus-visible .first-run-arrow {
transform: translateX(-2px) scaleX(-1);
}
/* The cockpit is an aircraft panel, not page chrome: its windows keep their
physical sides, so the data-layer stack goes back to the left edge instead
of landing on the utility controls. Gauges, tapes and the roll counters stay
left to right (speed left, altitude right, -10 not 10-); the cards keep
reading right to left. */
html[dir='rtl'] body.cockpit-mode #left-panel-stack {
left: var(--left-stack-x);
right: auto;
}
html[dir='rtl']
#cockpit-hud
:is(
.cockpit-instrument-row,
.cockpit-compass,
.cockpit-pitch-rail,
.cockpit-altitude-rim,
.cockpit-roll-arc,
.cockpit-vision-controls
) {
direction: ltr;
}
/* Feed text (news headlines, station names) arrives in its own language:
each line takes its direction from its first letter, so an English headline
reads and aligns left to right instead of moving its punctuation. */
html[dir='rtl']
:is(
.cockpit-news-list li *,
#radio-station-name,
#radio-station-tags,
.cockpit-radio-station
) {
unicode-bidi: plaintext;
}
/* Clock readouts (~0:05 / 5:19) are numbers, not prose. */
html[dir='rtl'] #bhote-koshi-event-panel [data-role='time'] {
direction: ltr;
unicode-bidi: isolate;
}
/* Physical controls keep their left-to-right sense: FM seek down/up and the
radio dial. The worded PREV / PLAY / NEXT row follows the reading
direction like any other button row. */
html[dir='rtl'] :is(.sdr-seek-row, .radio-tuner) {
direction: ltr;
}
/* Canvas labels (installations, map cards, detections) are measured and drawn
with left-to-right geometry: a card's text starts at its left padding. A
canvas inherits the page direction, which turned that start into the right
edge and pushed every title out of its card. */
html[dir='rtl'] canvas {
direction: ltr;
}
/* Scene callouts on the screen whiteboard: the card grows to the right of the
text's x, so right-to-left text ends there instead of starting there. The
injected stack (JetBrains Mono) has no Arabic glyphs; use the Arabic-aware
one. */
html[dir='rtl'] .gev-anno-text {
text-anchor: end;
font-family: var(--font-mono);
letter-spacing: 0;
}
/* Position counters built from bare numbers (01 / 25, 1 / 3) read as a number
pair, left to right, as an unspaced 1/3 does; otherwise the slash swaps
them and the current item looks like the total. Translated counters say
«من» instead. */
html[dir='rtl']
:is(
#bhote-koshi-event-panel [data-role='beat-index'],
#cockpit-brief-position,
[data-mission-index]
) {
direction: ltr;
unicode-bidi: isolate;
}
/* The radio tuner header is a left-to-right instrument row; its channel
readout («القناة 01 من 750») still reads in its own language. */
html[dir='rtl'] #radio-tuner-value {
unicode-bidi: plaintext;
}
/* The select chevron sits at the end of the field, where the text stops
(form-field.css pads that side through padding-inline-end). */
html[dir='rtl'] select.gev-field {
background-position: left 8px center;
}
html[dir='rtl'] select.gev-field--compact {
background-position: left 6px center;
}
/* A legend's range is printed under its colour ramp, which always runs low
to high from left to right; the numbers keep that order. */
html[dir='rtl'] .rail-card-scale {
direction: ltr;
}
+7 -6
View File
@@ -26,7 +26,7 @@
0 0 1.2rem rgba(0, 212, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.3);
-webkit-backdrop-filter: blur(24px) saturate(1.3);
text-align: left;
text-align: start;
transform: translateX(-50%);
transform-origin: bottom center;
animation: gev-voice-card-in 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -95,7 +95,7 @@
}
.gev-voice-card-dismiss {
margin-left: auto;
margin-inline-start: auto;
width: 1.1rem;
height: 1.1rem;
padding: 0;
@@ -288,7 +288,7 @@
content: counter(gev-referent);
display: inline-block;
min-width: 0.9rem;
margin-right: 0.3rem;
margin-inline-end: 0.3rem;
color: rgba(54, 220, 255, 0.72);
font-family: var(--font-mono);
font-size: 0.52rem;
@@ -415,7 +415,7 @@ body.recording-mode .gev-pointer-reticle {
-webkit-backdrop-filter: blur(24px) saturate(1.3);
color-scheme: dark;
font: 0.75rem/1.45 var(--font-sans);
text-align: left;
text-align: start;
transform: translateX(-50%);
}
:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel {
@@ -443,7 +443,7 @@ body.recording-mode .gev-pointer-reticle {
letter-spacing: 0.14em;
}
.gev-voice-panel-tabs {
margin-left: auto;
margin-inline-start: auto;
}
#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab {
display: inline-flex;
@@ -553,7 +553,8 @@ body.recording-mode .gev-pointer-reticle {
#gev-voice-timeout-select {
flex: 1 1 auto;
min-width: 0;
padding: 0 22px 0 0.45rem;
padding-block: 0;
padding-inline: 0.45rem 22px;
}
#gev-voice-timeout-input {
flex: 0 0 4.5rem;
+26 -8
View File
@@ -1,4 +1,4 @@
import { formatNumber, getLocale, msg, t } from '../i18n/index.js';
import { formatNumber, getLocale, isolate, 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.
@@ -41,20 +41,38 @@ export function quantitySeparator(label) {
export function formatQuantity(value, unit, options) {
const label = unitLabel(unit);
const number = formatNumber(value, { useGrouping: false, ...options });
return label ? `${number}${quantitySeparator(label)}${label}` : number;
return label
? isolate(`${number}${quantitySeparator(label)}${label}`)
: number;
}
/**
* A legend stop such as "0.1", "-40" or "300+" in the active language.
* Anything that is not a plain number is shown as given.
* Anything that is not a plain number is shown as given. In right-to-left
* text the stop is isolated, so its sign stays where it is written ("300+",
* "-40") instead of moving to the other side of the number.
*/
export function formatScaleLabel(label) {
const match = /^(-?\d+(?:\.\d+)?)(\+?)$/.exec(String(label ?? ''));
if (!match) return String(label ?? '');
const decimals = match[1].split('.')[1]?.length || 0;
return `${formatNumber(Number(match[1]), {
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}${match[2]}`;
return isolate(
`${formatNumber(Number(match[1]), {
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})}${match[2]}`,
);
}
/**
* A legend range with its unit: "960 — 1050+ hPa". The unit stays with the
* last number. In right-to-left text the unit is isolated as well, so a Latin
* unit keeps its own order ("°C", not "C°"). Units a source supplied as data
* (`verbatim`) are shown as given.
*/
export function formatScaleRange(low, high, units, verbatim = false) {
const unit = verbatim ? units || '' : unitLabel(units);
const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`;
return unit ? `${range}\u00a0${isolate(unit)}` : range;
}
+1 -1
View File
@@ -53,7 +53,7 @@ const chooserStyle = (focused) => ({ padding: '3px 9px', border: '1px solid #397
<p v-if="item.kind === 'text'" style="margin: 8px 0">{{ item.value }}</p>
<button v-else-if="item.kind === 'button'" type="button" :style="buttonStyle" :disabled="item.disabled" v-bind="item.hook ? { [item.hook]: '' } : {}" @click="controls?.click(item.id)">{{ item.label }}</button>
<label v-else-if="item.type === 'file'" :style="{ display: 'block', position: 'relative' }">{{ item.label }}<input type="file" :aria-label="item.label" :multiple="item.multiple" :webkitdirectory="item.directory || undefined" :style="fileInputStyle" @change="onFiles(item, $event)" @focus="focusedFile = item.id" @blur="focusedFile = null" /><span aria-hidden="true" :style="fileRowStyle"><span :style="chooserStyle(focusedFile === item.id)">{{ t('Choose files') }}</span><span>{{ chosenText(item) }}</span></span></label>
<label v-else style="display: block">{{ item.label }}<textarea v-if="item.multiline" rows="9" :aria-label="item.label" :style="fieldStyle(true)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)"></textarea><input v-else :type="item.type" :aria-label="item.label" :style="fieldStyle(false)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)" /></label>
<label v-else style="display: block">{{ item.label }}<textarea v-if="item.multiline" rows="9" dir="ltr" spellcheck="false" :aria-label="item.label" :style="fieldStyle(true)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)"></textarea><input v-else :type="item.type" :aria-label="item.label" :style="fieldStyle(false)" :value="item.value" @input="controls?.setValue(item.id, $event.target.value)" /></label>
</template>
</div>
<p role="status" v-status-text="statusText"></p>
+5 -2
View File
@@ -1,6 +1,6 @@
<script setup>
import { storeToRefs } from 'pinia';
import { t } from '../../../i18n/index.js';
import { formatNumber, t } from '../../../i18n/index.js';
import { vClassToggle } from '../../rail/classToggle.js';
import { useVoiceStore } from '../../stores/voice.js';
import VoiceCard from './VoiceCard.vue';
@@ -36,6 +36,9 @@ const vOwnedValue = {
el.value = value.text;
},
};
// "5 minutes" in the UI language, with the count's plural form ("15 دقيقة").
const minutesLabel = (count) =>
formatNumber(count, { style: 'unit', unit: 'minute', unitDisplay: 'long' });
const optional = (value) => (value == null ? undefined : String(value));
// The root's machine-state attributes, which the dock and theme CSS select on.
const rootData = (root) => ({
@@ -98,7 +101,7 @@ const rootData = (root) => ({
<label class="gev-voice-preference gev-voice-setting-title" for="gev-voice-timeout-select">{{ t('Voice inactivity timeout') }}</label>
<div class="gev-voice-timeout-controls">
<UiSelect id="gev-voice-timeout-select" class="gev-field" aria-describedby="gev-voice-timeout-note gev-voice-timeout-status" :model-value="view.preferences.timeoutChoice" @update:model-value="emit('timeout-select', $event)">
<option value="3">3 {{ t('Minutes') }}</option><option value="5">5 {{ t('Minutes') }}</option><option value="10">10 {{ t('Minutes') }}</option><option value="15">15 {{ t('Minutes') }}</option>
<option value="3">{{ minutesLabel(3) }}</option><option value="5">{{ minutesLabel(5) }}</option><option value="10">{{ minutesLabel(10) }}</option><option value="15">{{ minutesLabel(15) }}</option>
<option value="custom">{{ t('Custom') }}</option><option value="0">{{ t('None') }}</option>
</UiSelect>
<label class="gev-voice-timeout-unit" for="gev-voice-timeout-input">{{ t('Minutes') }}</label>
+1
View File
@@ -33,6 +33,7 @@
@import './src/ui/styles/segmented-control.css';
@import './src/ui/styles/kit.css';
@import './src/ui/styles/cjk.css';
@import './src/ui/styles/rtl.css';
@import './src/ui/styles/mobile-shell.css';
@import './src/ui/styles/annotations.css';
@import './src/ui/styles/cyber.css';