diff --git a/src/data/satelliteClass.js b/src/data/satelliteClass.js index 29bc44a63..ef2c32b6a 100644 --- a/src/data/satelliteClass.js +++ b/src/data/satelliteClass.js @@ -167,9 +167,14 @@ export function satelliteClassColor(group) { * @param {{ isIss?: boolean }} [options] Whether this is the ISS. * @returns {string} Display label. */ -export function satelliteClassLabel(group, { isIss = false } = {}) { +export function satelliteClassLabel( + group, + { isIss = false, localize = true } = {}, +) { const { klass, subtype } = satelliteClassOf(group, { isIss }); - const base = t(CLASS_DISPLAY_KEYS[klass].label); + // localize: false keeps the English label for data the AI tools query. + const label = CLASS_DISPLAY_KEYS[klass].label; + const base = localize ? t(label) : label; return subtype ? `${base} · ${subtype}` : base; } diff --git a/src/i18n/index.js b/src/i18n/index.js index 0cb04c995..14855be91 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -109,6 +109,18 @@ export function tn(count, one, other, values) { return interpolate(pattern, { ...values, count }); } +/** + * Translate text that may be ours or may come from a provider: a catalog + * entry is used when there is one, anything else shows as given, without a + * missing-translation warning. + */ +export function translateKnown(text) { + const catalog = CATALOGS[currentLocale]; + if (!catalog || typeof text !== 'string') return text; + const entry = catalog[text]; + return typeof entry === 'string' ? entry : text; +} + /** Mark English text that is stored in data and translated later by t(). */ export function msg(text) { return text; diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index b2901c984..8c8115e82 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2030,5 +2030,7 @@ "subject": "sujeto", "{value}m": "{value} m", "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Teselas de Google 3D no disponibles: revisa las restricciones de API de la clave, la cuota o la red", - "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Necesita {envVars} — añádela en Ajustes de proveedores — o un token de Cesium ion para la ruta alojada en ion" + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Necesita {envVars} — añádela en Ajustes de proveedores — o un token de Cesium ion para la ruta alojada en ion", + "Comparison in use by another scene": "La comparación está en uso por otra escena", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "Una lectura en un punto necesita un pronóstico cargado y la Tierra en el centro de la vista." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1b840bf7e..aa9dd4562 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2062,5 +2062,7 @@ "subject": "объект", "{value}m": "{value} м", "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Тайлы Google 3D недоступны — проверьте ограничения API ключа, квоту или сеть", - "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Нужен {envVars} — добавьте его в «Настройках провайдеров» — или токен Cesium ion для маршрута через ion" + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Нужен {envVars} — добавьте его в «Настройках провайдеров» — или токен Cesium ion для маршрута через ion", + "Comparison in use by another scene": "Сравнение занято другой сценой", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "Для показания в точке нужен загруженный прогноз и Земля в центре вида." } diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index f2f6cb2c7..bd7c45c4e 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -294,7 +294,8 @@ export function createAlprCamerasLayer({ source, services } = {}) { id: LAYER_ID, name: 'ALPR Cameras', icon: '📷', - source: t(source.label || msg('Mapped camera locations')), + // English in layer state; the layer row translates it. + source: source.label || msg('Mapped camera locations'), updateInterval: 0, statsRefreshInterval: 1000, init(viewer) { diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index 045b7f106..df3b44c9e 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -557,7 +557,8 @@ export function directionsStats(state) { armed, pointerBlocked, } = state; - const source = t('OSM routing'); + // English in layer state; the layer row translates it. + const source = 'OSM routing'; // i18n-ignore: source name, translated by layerPanel if (pointerBlocked) { return { count: route?.steps.length || 0, diff --git a/src/layers/recentImagery/index.js b/src/layers/recentImagery/index.js index daf9a917d..969a6a8de 100644 --- a/src/layers/recentImagery/index.js +++ b/src/layers/recentImagery/index.js @@ -47,7 +47,7 @@ export const SPLIT_PUBLISH_MS = 400; /** The three modes, in share-link order (`m` = index). */ export const MODES = Object.freeze(['image', 'basemap', 'ab']); -const COMPARISON_IN_USE = 'Comparison in use by another scene'; +const COMPARISON_IN_USE = msg('Comparison in use by another scene'); // Kept English in the snapshot (AI tools read it); the card translates them. const COMPARISON_UNAVAILABLE = msg('Esri map unavailable · no swipe'); const COMPARISON_NEEDS_GLOBE = msg('Swipe needs a globe map'); diff --git a/src/layers/satellites/records.js b/src/layers/satellites/records.js index eddae75b6..d4fb4d076 100644 --- a/src/layers/satellites/records.js +++ b/src/layers/satellites/records.js @@ -37,7 +37,9 @@ export function mapAnalystRecord(raw) { // Empty records stay class-less; the class table's fallback is VISUAL and // must not leak onto a row that has no satellite identity. satelliteClass: - noradId || group ? satelliteClassLabel(group, { isIss }) : null, + noradId || group + ? satelliteClassLabel(group, { isIss, localize: false }) + : null, group, }; } diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 7bdb1d29f..2986d5344 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -239,7 +239,10 @@ export function createTracking({ state: layerState, services, parts, source }) { name, operator: '', noradId: String(noradId), - class: satelliteClassLabel(sat.group, { isIss: noradId === ISS_NORAD }), + class: satelliteClassLabel(sat.group, { + isIss: noradId === ISS_NORAD, + localize: false, + }), altitude: altitudeKm === null ? '' : `${altitudeKm.toLocaleString('en-US')} km`, }, diff --git a/src/layers/wind/inspection.js b/src/layers/wind/inspection.js index dd57658f0..16cb2e91e 100644 --- a/src/layers/wind/inspection.js +++ b/src/layers/wind/inspection.js @@ -65,8 +65,9 @@ export function inspectWindAtCenter( model, validTime, status, - explanation: + explanation: msg( 'A location reading needs a loaded forecast and the Earth at the center of the view.', + ), }; const location = cesium.Cartographic.fromCartesian(point, ellipsoid); const lon = cesium.Math.toDegrees(location.longitude); diff --git a/src/maps/imageryHost.js b/src/maps/imageryHost.js index 2e227b47c..3953ef69a 100644 --- a/src/maps/imageryHost.js +++ b/src/maps/imageryHost.js @@ -6,7 +6,13 @@ */ /** Guidance shown when the active map stack cannot host draped imagery. */ -export const NO_IMAGERY_HOST = 'Hidden by this map source · choose a globe map'; +// Marks text for i18n extraction without importing i18n here; the UI +// translates it where it is shown. +const msg = (text) => text; + +export const NO_IMAGERY_HOST = msg( + 'Hidden by this map source · choose a globe map', +); /** * Resolve the imagery layer collection for the active map stack. diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 65157aab3..dbb7dc942 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -5,7 +5,7 @@ export { layerFeedState } from '../data/feedState.js'; import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; import { keySetupRequirementEnvVars } from '../keySetupCore.mjs'; import { createWeatherPanel } from './weatherPanel.js'; -import { t, msg } from '../i18n/index.js'; +import { msg, t, translateKnown } from '../i18n/index.js'; // Feed-state enum VALUES are machine identifiers (layerFeedState contract, QA // hooks, CSS feed-* classes); only the final label value translates, at this @@ -176,6 +176,8 @@ const SOURCE_LABEL_KEYS = new Map( msg('NOAA nowCOAST · OBSERVED'), msg('NASA FIRMS · LIVE'), msg('OSM routing'), + msg('Mapped camera locations'), + msg('OpenStreetMap · community mapped'), ].map((key) => [key, key]), ); @@ -721,8 +723,9 @@ export class LayerPanel { { source }, ); } - const presentedError = - stats.error || stats.lastError || stats.managerRefreshError; + const presentedError = translateKnown( + stats.error || stats.lastError || stats.managerRefreshError, + ); if (presentedError) { if (typeof stats.retryInSec === 'number' && stats.retryInSec > 0) { return t('{state} · {source} · {detail} · retry {seconds}s', { @@ -751,14 +754,14 @@ export class LayerPanel { if (stats.loading) { const loadingLabel = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() - ? stats.loadingLabel.trim() + ? translateKnown(stats.loadingLabel.trim()) : t('loading...'); return t('{source} · {loadingLabel}', { source, loadingLabel }); } if (feedState === 'fallback') { const detail = typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim() - ? stats.loadingLabel.trim() + ? translateKnown(stats.loadingLabel.trim()) : stats.coverage || ago; return t('{state} · {source} · {detail}', { state: stateLabel, @@ -803,7 +806,7 @@ export class LayerPanel { if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) { return t('{source} · {loadingLabel}', { source, - loadingLabel: stats.loadingLabel.trim(), + loadingLabel: translateKnown(stats.loadingLabel.trim()), }); } return t('{source} · {ago}', { source, ago }); @@ -861,7 +864,9 @@ export class LayerPanel { ? t('UNAVAILABLE', { context: 'status chip' }) : stateText; const keyGuidance = unavailable - ? String(layer.stats.error || t('Data source unavailable')) + ? String( + translateKnown(layer.stats.error) || t('Data source unavailable'), + ) : layerKeyRequirementTooltip(layer); // Name the missing key on the control itself: a row reading KEY REQUIRED // without saying WHICH key leaves a dead control and no next step. Empty diff --git a/src/ui/recentImagery.js b/src/ui/recentImagery.js index a3bae70e4..c7828e174 100644 --- a/src/ui/recentImagery.js +++ b/src/ui/recentImagery.js @@ -8,7 +8,7 @@ * strip and selection bar are this adapter's own keyed DOM. Source toggles * live on the data-panel row. */ -import { t, tn, msg, formatNumber } from '../i18n/index.js'; +import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js'; import { createRailCardBlocks } from './railCardBlocks.js'; import { createRailCards } from './railCards.js'; import { createImagerySplit } from './imagerySplit.js'; @@ -39,7 +39,7 @@ function localizeNotice(text) { if (!text) return ''; if (LAYER_NOTICES.has(text)) return t(text); const tooLarge = TOO_LARGE.exec(text); - if (!tooLarge) return text; + if (!tooLarge) return translateKnown(text); const values = { size: formatQuantity(Number(tooLarge[2].replace(/,/g, '')), 'km', { useGrouping: true,