diff --git a/docs/translations/ar.md b/docs/translations/ar.md index e97e31ac2..8b5df7e82 100644 --- a/docs/translations/ar.md +++ b/docs/translations/ar.md @@ -19,10 +19,12 @@ Translation sources: abiyeenzo (#915). - **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 ``. The title, style indicator and both 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. Media transport (previous / play / next) and the radio dial stay left to right, as on any player. 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. ## Units -Distances and speeds take Arabic abbreviations after the number, with a space: كم، م، كم/س، كم/ث، م/ث، ميل/س، عقدة (knots)، قدم (feet)، مم. Time: ث (seconds)، د (minutes)، س (hours)، يوم. 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ش`. +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). 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 @@ -55,6 +57,8 @@ Every form must keep `{count}`. For one and two, Arabic names the quantity with | globe / map / map source / basemap | الكرة الأرضية / الخريطة / مصدر الخريطة / الخريطة الأساسية | | tiles | بلاطات | | aircraft / flight / military flights | طائرة / رحلة / الرحلات العسكرية | +| Live Flights / Live Vessels (layers) | تتبّع الرحلات / السفن المباشر (not الرحلات المباشرة) | +| call sign | رمز النداء | | vessel, ship | سفينة | | satellite | قمر صناعي | | launch (rocket) / space mission | إطلاق / مهمة فضائية | @@ -78,12 +82,13 @@ Every form must keep `{count}`. For one and two, Arabic names the quantity with | transit / bike share | النقل العام / الدراجات المشتركة | | Previous / Next | السابق / التالي | | compass points (N, NNE, …) | شمال، شمال شمال شرق، … | -| forecast / valid / issued | توقع / صالح حتى / صدر | +| forecast / valid (time) / issued | توقع / عند، وقت التوقع (not صالح حتى) / صدر | | observation / observed | رصد / مرصود | | field (weather) / model | حقل / نموذج | +| parameters / ellipsoid | المعلمات / المجسّم الإهليلجي | | scene cloud | غيوم المشهد | | box (Recent Imagery area) / pin | منطقة / تثبيت | -| THIS (voice pointer) | المُشار إليه | +| THIS (voice pointer) | هذا | | outline / mark (map annotation) | مخطط / علامة، تعليم | | share (a view or link) | مشاركة | | AI agent | وكيل ذكاء اصطناعي | diff --git a/src/i18n/ar-display.test.mjs b/src/i18n/ar-display.test.mjs index f650a9a1d..4cde2c4eb 100644 --- a/src/i18n/ar-display.test.mjs +++ b/src/i18n/ar-display.test.mjs @@ -1,6 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { formatDate, formatNumber, getLocale, setLocale, t, tn } from './index.js'; +import { + formatDate, + formatNumber, + getLocale, + keepCodesWithValues, + setLocale, + t, + tn, +} from './index.js'; import { applyDocumentLanguage, intlLocale, @@ -109,7 +117,19 @@ test('Arabic uses all six plural forms and spaced HUD units', () => { 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', + ); setLocale('ru'); + assert.equal(keepCodesWithValues('HDG 68°'), 'HDG 68°'); assert.equal(formatCockpitDistance(14000), '14 КМ'); } finally { setLocale(previous); diff --git a/src/i18n/index.js b/src/i18n/index.js index bd28fa346..df6dc93b2 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -53,6 +53,20 @@ export function isolate(text) { return isRtlLocale(currentLocale) ? `\u2068${text}\u2069` : String(text); } +const CODE_BEFORE_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.', diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index a59ddc1d4..1f7b0f7f0 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -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 || ''; } diff --git a/src/overlays/worldOverlayTokens.js b/src/overlays/worldOverlayTokens.js index 7fcf9dc64..f00ae5cf4 100644 --- a/src/overlays/worldOverlayTokens.js +++ b/src/overlays/worldOverlayTokens.js @@ -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, diff --git a/src/scenes/recipes.js b/src/scenes/recipes.js index ba3940a57..d1f0fe548 100644 --- a/src/scenes/recipes.js +++ b/src/scenes/recipes.js @@ -1069,17 +1069,30 @@ export function displaySceneTitle(scene) { export function displayShotTitle(title, shot = undefined, scene = undefined) { const text = typeof title === 'string' ? title : ''; const fromRecipe = shot - ? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene?.id, text) + ? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene, text) : true; if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text); const numbered = /^Shot ([1-9]\d*)$/.exec(text); return numbered ? t('Shot {n}', { n: numbered[1] }) : text; } -/** Whether the built-in recipe behind a scene declares a shot with this title. */ -function recipeDeclaresShotTitle(sceneId, title) { - const recipe = sceneId ? getSceneRecipeById(sceneId) : null; - return Boolean( - recipe?.cameraPath?.some((keyframe) => keyframe?.title === title), +/** + * Whether the built-in recipe behind a scene declares a shot with this title: + * its own keyframes, or the bootstrap shots of a shot pack applied to the + * scene (those shots carry no sourcePackId; the scene's pack marker vouches + * for them). + */ +function recipeDeclaresShotTitle(scene, title) { + const declares = (cameraPath) => + Boolean(cameraPath?.some((keyframe) => keyframe?.title === title)); + const recipe = scene?.id ? getSceneRecipeById(scene.id) : null; + if (declares(recipe?.cameraPath)) return true; + const packs = Array.isArray(scene?.appliedShotPacks) + ? scene.appliedShotPacks + : []; + return packs.some((marker) => + declares( + getSceneAppendRecipeById(marker?.id)?.legacySceneBootstrap?.cameraPath, + ), ); } diff --git a/src/scenes/sceneTitleDisplay.test.mjs b/src/scenes/sceneTitleDisplay.test.mjs index 604e01c49..096c087cc 100644 --- a/src/scenes/sceneTitleDisplay.test.mjs +++ b/src/scenes/sceneTitleDisplay.test.mjs @@ -87,6 +87,25 @@ test('a built-in shot title localizes only for a recipe-sourced shot', () => { } }); +test('bootstrap shots of an applied shot pack localize their built-in titles', () => { + setLocale('ru'); + const title = 'Global Incident Context'; + const shot = { id: 'shot-1', title }; + // A legacy scene upgraded in place: its bootstrap shots have no + // sourcePackId, but the scene carries the pack marker. + const upgraded = { + id: 'scene-legacy', + appliedShotPacks: [{ id: 'bhote-koshi-nepal-evidence-pack', version: 12 }], + }; + assert.equal( + displayShotTitle(title, shot, upgraded), + displayShotTitle(title), + ); + assert.notEqual(displayShotTitle(title, shot, upgraded), title); + // The same typed title in a scene without the pack stays as typed. + assert.equal(displayShotTitle(title, shot, { id: 'scene-user' }), title); +}); + test('the Scene status line shows an operator-typed shot title as typed', async () => { const { createScenePlaybackAdapter } = await import('./playbackAdapter.js'); const statuses = []; diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 070c47f64..d735b5d70 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -1,5 +1,5 @@ import { updateStatusBadge } from './statusBadge.js'; -import { t, tn } from '../i18n/index.js'; +import { keepCodesWithValues, t, tn } from '../i18n/index.js'; import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js'; import { createCctvVideoSurface } from './cctvVideo.js'; @@ -167,18 +167,20 @@ export function _renderCctvState(state) { : ''; const projLabel = state?.showProjection !== false ? t('MONITOR') : t('OFF'); - this._cctvMeta.textContent = t( - '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', - { - city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg)}°`, - fov: `${Math.round(activeCamera.fovDeg)}°`, - range: Math.round(activeCamera.rangeM), - projection: projLabel, - calBadge: calBadge ? ` · ${calBadge}` : '', - provider, - status: `${credit}${statusMsg}`, - }, + this._cctvMeta.textContent = keepCodesWithValues( + t( + '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', + { + city: activeCamera.city, + heading: `${Math.round(activeCamera.headingDeg)}°`, + fov: `${Math.round(activeCamera.fovDeg)}°`, + range: Math.round(activeCamera.rangeM), + projection: projLabel, + calBadge: calBadge ? ` · ${calBadge}` : '', + provider, + status: `${credit}${statusMsg}`, + }, + ), ); } else if (cameras.length > 0) { this._cctvMeta.textContent = enabled diff --git a/src/ui/sceneSharing.js b/src/ui/sceneSharing.js index 8c9843eba..035acdffb 100644 --- a/src/ui/sceneSharing.js +++ b/src/ui/sceneSharing.js @@ -80,7 +80,13 @@ export function createSceneDialog(title, onClose) { border: '1px solid #397080', font: multiline ? '12px monospace' : 'inherit', }); - if (multiline) node.rows = 9; + if (multiline) { + node.rows = 9; + // The multiline fields hold JSON: code reads left to right in every + // language, or braces, quotes and minus signs move in Arabic. + node.dir = 'ltr'; + node.spellcheck = false; + } wrapper.append(node); if (type === 'file') fileChooser(wrapper, node); body.append(wrapper); diff --git a/src/ui/styles/rtl.css b/src/ui/styles/rtl.css index 243076060..c8463037b 100644 --- a/src/ui/styles/rtl.css +++ b/src/ui/styles/rtl.css @@ -37,8 +37,8 @@ html:lang(ar) { '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; + '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. */ @@ -139,3 +139,40 @@ html[dir='rtl'] :is(.context-radio-mini-transport, .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 + ) { + 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; +}