diff --git a/docs/translations/ar.md b/docs/translations/ar.md index df224805b..b5ab5acdf 100644 --- a/docs/translations/ar.md +++ b/docs/translations/ar.md @@ -8,8 +8,8 @@ Translation sources: abiyeenzo (#915). - 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 «جارٍ …» («جارٍ التحميل»، «… جارٍ تشغيل اللقطة»); finished actions use the passive («تم الحفظ»، «أُلغي التحميل»). Data that refreshes itself «يُحدَّث» (يتحدّث means "speaks"). A sentence about a `{placeholder}` whose gender and number vary stays impersonal: «{layers}: تم التفعيل للتو». -- A label followed by its value takes a colon, so it does not read as a sentence: «الصوت: نشط»، «السياق: {value}». +- 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 stays impersonal: «{layers}: تم التفعيل للتو». +- 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; well-known cities and countries are written in Arabic (أوستن، طوكيو، نيبال). @@ -22,7 +22,7 @@ Translation sources: abiyeenzo (#915). - 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. +- 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 @@ -71,12 +71,13 @@ Every form must keep `{count}`. For one and two, Arabic names the quantity with | 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 | واجهة نظيفة | +| Clean UI / EXIT CLEAN VIEW | واجهة نظيفة / الخروج من الواجهة النظيفة (not العرض) | | cockpit | القمرة (قمرة القيادة) | | heading / altitude / ground speed | الاتجاه (HDG) / الارتفاع / السرعة الأرضية | | Street Level / Street View | مستوى الشارع / Street View | @@ -90,7 +91,7 @@ Every form must keep `{count}`. For one and two, Arabic names the quantity with | forecast / valid (time) / issued | توقع / عند، وقت التوقع (not صالح حتى) / صدر | | observation / observed | رصد / مرصود | | heard (aircraft received by the SDR) | الملتقطة | -| upstream (camera feed) | المصدر الأصلي | +| upstream (camera feed) | المصدر الأصلي (in every CCTV message, not bare المصدر) | | field (weather) / model | حقل / نموذج | | parameters / ellipsoid | المعلمات / المجسّم الإهليلجي | | scene cloud | غيوم المشهد | @@ -100,6 +101,7 @@ Every form must keep `{count}`. For one and two, Arabic names the quantity with | share (a view or link) | مشاركة | | AI agent | وكيل ذكاء اصطناعي | | 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 ساعات المهلة) | diff --git a/src/i18n/ar-display.test.mjs b/src/i18n/ar-display.test.mjs index df21a45d2..5ba81277b 100644 --- a/src/i18n/ar-display.test.mjs +++ b/src/i18n/ar-display.test.mjs @@ -18,7 +18,11 @@ import { import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js'; import { formatCockpitDistance } from '../ui/cockpitPresentation.js'; import { READOUT_WORDS } from '../layers/recentImagery/display.js'; -import { formatQuantity, formatScaleLabel } from '../ui/unitLabels.js'; +import { + formatQuantity, + formatScaleLabel, + formatScaleRange, +} from '../ui/unitLabels.js'; test('Arabic resolves from URL, saved preference and browser language', () => { assert.equal( @@ -133,6 +137,11 @@ test('Arabic uses all six plural forms and spaced HUD units', () => { '\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) => diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 046252594..6b310596f 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -166,7 +166,7 @@ "READY": "جاهز", "ACTIVE STYLE": "النمط النشط", "Return UI controls": "إعادة عناصر التحكم", - "EXIT CLEAN VIEW": "الخروج من العرض النظيف", + "EXIT CLEAN VIEW": "الخروج من الواجهة النظيفة", "Expand Visual Presets": "توسيع الأنماط البصرية", "VISUAL PRESETS": "الأنماط البصرية", "Pin visual presets": "تثبيت الأنماط البصرية", @@ -551,7 +551,7 @@ "PROJECTION ON": "الإسقاط مفعّل", "CALIBRATION": "المعايرة", "ADJUST": "ضبط", - "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "اسحب الكاميرا في العالم: تدور الحلقات وتحرّك الأسهم الموضع وتضبط المقابض المدى وزاوية الرؤية", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "اسحب الكاميرا في العالم: الحلقات لتدويرها، والأسهم لتحريكها، والمقابض لضبط المدى وزاوية الرؤية", "Camera pose — click a value to type": "وضعية الكاميرا — انقر على قيمة لكتابتها", "Heading (compass °) — click to type": "الاتجاه (بوصلة °) — انقر للكتابة", "Pitch (° up/down) — click to type": "الميل (° أعلى/أسفل) — انقر للكتابة", @@ -814,7 +814,7 @@ "VISUAL": "مرئية", "Brightest naked-eye objects — CelesTrak visual group": "ألمع الأجرام المرئية بالعين المجردة — مجموعة CelesTrak المرئية", "COMMS": "اتصالات", - "Broadband constellation shell — shown only in DENSE mode": "غلاف كوكبة النطاق العريض — يظهر في وضع الكثيف فقط", + "Broadband constellation shell — shown only in DENSE mode": "غلاف كوكبة النطاق العريض — يظهر في وضع «كثيف» فقط", "refreshing...": "جارٍ التحديث…", "KEY REQUIRED": "المفتاح مطلوب", "STALE · cached {age}": "متقادم · مخزّن {age}", @@ -899,6 +899,8 @@ "awaiting usable AIS positions…": "بانتظار مواضع AIS صالحة…", "awaiting first AIS message…": "بانتظار أول رسالة AIS…", "EST. DOWNRANGE": "تقدير على امتداد المسار", + "CONFIRMED": "مؤكَّد", + "PAD / RTLS": "منصة الإطلاق / RTLS", "Pick a shape, then click the map.": "اختر شكلًا، ثم انقر على الخريطة.", "Enter to place the pin, Esc to cancel.": "Enter لوضع الدبوس، وEsc للإلغاء.", "Click where the pin goes.": "انقر حيث تريد وضع الدبوس.", @@ -938,7 +940,7 @@ "General emergency": "طوارئ عامة", "Unlawful interference": "تدخل غير مشروع", "Radio failure": "عطل في الاتصال اللاسلكي", - "Downed aircraft": "طائرة سقطت", + "Downed aircraft": "طائرة ساقطة", "Minimum fuel": "الحد الأدنى من الوقود", "Lifeguard (medical priority)": "إسعاف جوي (أولوية طبية)", "Rasuwagadhi witness video": "فيديو شاهد عيان في Rasuwagadhi", @@ -1599,7 +1601,7 @@ "WebUSB needs desktop Chrome or Edge": "يتطلب WebUSB متصفح Chrome أو Edge على سطح المكتب", "connect a receiver in Radio": "وصّل جهاز استقبال في «الراديو»", "receiver is in FM mode": "جهاز الاستقبال في وضع FM", - "listening": "استماع", + "listening": "جارٍ الاستماع", "USB error": "خطأ USB", "${amount}K": "{amount} ألف دولار", "${amount}M": "{amount} مليون دولار", @@ -1622,8 +1624,8 @@ "many": "{acres} آكر", "other": "{acres} آكر" }, - "{percent}% contained": "تمت السيطرة على {percent}%", - "containment unknown": "نسبة السيطرة غير معروفة", + "{percent}% contained": "الاحتواء: {percent}%", + "containment unknown": "نسبة الاحتواء غير معروفة", "{cause} cause": "السبب: {cause}", "{personnel} personnel": { "zero": "{personnel} فرد", @@ -1643,11 +1645,11 @@ "Unnamed incident": "حادث بلا اسم", "Open {title} on InciWeb": "فتح {title} على InciWeb", "Perimeter source unavailable": "مصدر محيطات الحرائق غير متاح", - "Not contained or unknown": "غير مسيطر عليه أو غير معروف", - "Under 50% contained": "السيطرة أقل من 50%", - "50–99% contained": "السيطرة 50–99%", - "Fully contained": "مسيطر عليه بالكامل", - "Colour shows reported containment. Perimeters are simplified to about 100 m.": "يبيّن اللون نسبة السيطرة المُبلَّغ عنها. المحيطات مبسّطة إلى نحو 100 م.", + "Not contained or unknown": "بلا احتواء أو غير معروف", + "Under 50% contained": "الاحتواء: أقل من 50%", + "50–99% contained": "الاحتواء: 50–99%", + "Fully contained": "احتواء كامل", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "يبيّن اللون نسبة الاحتواء المُبلَّغ عنها. المحيطات مبسّطة إلى نحو 100 م.", "Malformed perimeter snapshot": "لقطة المحيطات مشوّهة", "radio tag|All": "الكل", "Directory refresh degraded; showing the previous station catalog.": "تحديث الدليل متدهور؛ يُعرض فهرس المحطات السابق.", @@ -2199,14 +2201,14 @@ "Live stream connected": "البث المباشر متصل", "Snapshot feed connected": "مصدر اللقطات متصل", "Media fetch failed": "فشل جلب الوسائط", - "Upstream snapshot active": "لقطة المصدر نشطة", + "Upstream snapshot active": "لقطة المصدر الأصلي نشطة", "Fallback Street View frame": "إطار بديل من Street View", - "Upstream unavailable": "المصدر غير متاح", + "Upstream unavailable": "المصدر الأصلي غير متاح", "No source configured": "لم يُعَدّ أي مصدر", "Configured source": "المصدر المُعَدّ", "Configured Source": "المصدر المُعَدّ", "Synthetic fallback": "بديل محاكى", - "Upstream HTTP {status}": "المصدر: HTTP {status}", + "Upstream HTTP {status}": "المصدر الأصلي: HTTP {status}", "Unexpected media type {type}": "نوع وسائط غير متوقع: {type}", "{value} KM/H": "{value} كم/س", "{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}", @@ -2294,7 +2296,7 @@ "Data source unavailable": "مصدر البيانات غير متاح", "Local FM stopped: internet radio started": "توقف FM المحلي: بدأ راديو الإنترنت", "IDLE": "خامل", - "TUNING": "ضبط", + "TUNING": "جارٍ الضبط", "STREAMING": "استقبال", "UNSUPPORTED": "غير مدعوم", "idle": "خامل", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a32cc9d37..e2877e828 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -739,6 +739,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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 319092f8a..881d1e51f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -216,6 +216,8 @@ "Pause replay": "Приостановить повтор", "Cancel replay": "Отменить повтор", "EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ", + "CONFIRMED": "ПОДТВЕРЖДЕНО", + "PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS", "PLANNED · ": "ПЛАН · ", "SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ", "RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 311b21944..e15a8a41a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -811,6 +811,8 @@ "Pause replay": "暂停回放", "Cancel replay": "取消回放", "EST. DOWNRANGE": "预估落点距离", + "CONFIRMED": "已确认", + "PAD / RTLS": "发射台 / RTLS", "PLANNED · ": "计划 · ", "SUPPLIED TRAJECTORY POINTS": "提供的轨迹点", "RECONSTRUCTED ESTIMATE": "重建估算", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c2223f68f..43b315333 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -806,6 +806,8 @@ "Pause replay": "暫停回放", "Cancel replay": "取消回放", "EST. DOWNRANGE": "預估落點距離", + "CONFIRMED": "已確認", + "PAD / RTLS": "發射臺 / RTLS", "PLANNED · ": "計劃 · ", "SUPPLIED TRAJECTORY POINTS": "提供的軌跡點", "RECONSTRUCTED ESTIMATE": "重建估算", diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index f2e237b72..95eeede6f 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -6,6 +6,15 @@ import { } 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 @@ -251,7 +260,7 @@ 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}` + ? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${accuracyLabel(endpoint.accuracy)}` : stage.downrangeKm > 0 ? t('{value} KM DOWNRANGE', { value: stage.downrangeKm.toLocaleString(), diff --git a/src/ui/railCardBlocks.js b/src/ui/railCardBlocks.js index b63d0a6a0..4ddf61727 100644 --- a/src/ui/railCardBlocks.js +++ b/src/ui/railCardBlocks.js @@ -1,7 +1,7 @@ import { t } from '../i18n/index.js'; import { syncChipGroup } from './chipGroup.js'; import { syncRowList } from './rowList.js'; -import { formatScaleLabel, unitLabel } from './unitLabels.js'; +import { formatScaleRange } from './unitLabels.js'; import { createSlider } from './slider.js'; const set = (node, key, value) => { @@ -16,13 +16,6 @@ const order = (parent, nodes) => parent.insertBefore(node, parent.children[i] || null); }); -// "960 — 1050+ гПа": the unit stays with the last number. -const scaleText = (low, high, units) => { - const unit = unitLabel(units); - const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`; - return unit ? `${range}\u00a0${unit}` : range; -}; - /** Optional, ordered blocks without disclosure state. All controls are keyed. */ export function createRailCardBlocks({ container, cardId, onParams }) { const document = container.ownerDocument; @@ -309,7 +302,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) { block.scale, 'textContent', visible - ? scaleText(legend.labels[0], legend.labels.at(-1), legend.units) + ? formatScaleRange( + legend.labels[0], + legend.labels.at(-1), + legend.units, + ) : '', ); } else if (props.type === 'actions') { diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 2366371f7..d7359dfec 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -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); diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index b72e1a5d3..e5b351f77 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -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({ diff --git a/src/ui/styles/voice-card.css b/src/ui/styles/voice-card.css index 21ad26bd1..1e9676b29 100644 --- a/src/ui/styles/voice-card.css +++ b/src/ui/styles/voice-card.css @@ -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; @@ -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; diff --git a/src/ui/unitLabels.js b/src/ui/unitLabels.js index 236545443..054690263 100644 --- a/src/ui/unitLabels.js +++ b/src/ui/unitLabels.js @@ -51,3 +51,14 @@ export function formatScaleLabel(label) { })}${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°"). + */ +export function formatScaleRange(low, high, units) { + const unit = unitLabel(units); + const range = `${formatScaleLabel(low)} — ${formatScaleLabel(high)}`; + return unit ? `${range}\u00a0${isolate(unit)}` : range; +}