mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge pull request #1479 from bilawalsidhu/german/hal-644-ar
i18n: Arabic (ar), the first right-to-left language
This commit is contained in:
62 files changed
+4485
-171
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "重建估算",
|
||||
|
||||
@@ -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": "重建估算",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,7 +102,8 @@ function metaText(state, enabled, activeCamera, catalogCount) {
|
||||
? _calBadgeLabel(activeCamera.calBadge)
|
||||
: '';
|
||||
const projLabel = state?.showProjection !== false ? t('MONITOR') : t('OFF');
|
||||
return t(
|
||||
return keepCodesWithValues(
|
||||
t(
|
||||
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
|
||||
{
|
||||
city: activeCamera.city,
|
||||
@@ -114,6 +115,7 @@ function metaText(state, enabled, activeCamera, catalogCount) {
|
||||
provider,
|
||||
status: `${credit}${statusMsg}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (catalogCount > 0)
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+4
-4
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+23
-5
@@ -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]), {
|
||||
return isolate(
|
||||
`${formatNumber(Number(match[1]), {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
})}${match[2]}`;
|
||||
})}${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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
Reference in new issue
Block a user