diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 0bbf132fa..e5cef57f8 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1176,7 +1176,8 @@ "src/i18n/index.js", "src/i18n/locale.js", "src/i18n/locales/es.json", - "src/i18n/locales/ru.json" + "src/i18n/locales/ru.json", + "src/ui/unitLabels.js" ], "external": ["cesium"] }, diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ed910dfff..a71a61cde 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2075,5 +2075,12 @@ "Trance": "Trance", "World": "Músicas del mundo", "{speed}KT": "{speed} KN", - "{heading}DEG": "{heading}°" + "{heading}DEG": "{heading}°", + "At least {count}": "Al menos {count}", + "Named areas ({n})": "Zonas con nombre ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "VENTANA DE VUELOS / BUQUES DE {distance}", + "mapped matches within {km} km of the subject": "coincidencias mapeadas a menos de {km} km del objetivo", + "none mapped within {km} km; not a complete 250 km survey": "ninguna mapeada a menos de {km} km; no es un barrido completo de 250 km", + "mapped matches from the loaded viewport": "coincidencias mapeadas en la vista cargada", + "viewport feed is not a complete 250 km survey": "los datos de la vista no son un barrido completo de 250 km" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 52325ffe3..3608e5fad 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2107,5 +2107,12 @@ "Trance": "Транс", "World": "Музыка мира", "{speed}KT": "{speed} УЗ", - "{heading}DEG": "{heading}°" + "{heading}DEG": "{heading}°", + "At least {count}": "Не менее {count}", + "Named areas ({n})": "Именованные зоны ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "ОКНО РЕЙСОВ / СУДОВ {distance}", + "mapped matches within {km} km of the subject": "совпадения на карте в радиусе {km} км от объекта", + "none mapped within {km} km; not a complete 250 km survey": "на карте нет в радиусе {km} км; это не полный обзор 250 км", + "mapped matches from the loaded viewport": "совпадения на карте в загруженной области", + "viewport feed is not a complete 250 km survey": "данные видимой области — не полный обзор 250 км" } diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 3539c437f..5fe2a0ac8 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -1,9 +1,9 @@ import { formatAwarenessLabel, - formatAwarenessDistance, AWARENESS_RADIUS_M, } from '../../data/militaryAwarenessEngine.js'; -import { t } from '../../i18n/index.js'; +import { formatNumber, t, translateKnown } from '../../i18n/index.js'; +import { formatQuantity } from '../../ui/unitLabels.js'; import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js'; /** Format a cohort total without presenting a retained cap as exact. */ @@ -15,6 +15,33 @@ export function formatAwarenessCount(summary = {}) { : count; } +/** The count as the panel shows it, in the UI language. */ +function presentAwarenessCount(summary = {}) { + if (!Number.isFinite(summary.count)) return '?'; + const count = formatNumber(summary.count); + return summary.complete === false || summary.truncated === true + ? t('At least {count}', { count }) + : count; +} + +/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */ +function presentAwarenessDistance(meters) { + if (!Number.isFinite(meters) || meters < 0) return '—'; + if (meters < 1000) return formatQuantity(Math.round(meters), 'm'); + const digits = meters < 10000 ? 1 : 0; + return formatQuantity(meters / 1000, 'km', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +/** The cohort's reason, kept English in data, shown in the UI language. */ +function presentAwarenessReason(summary = {}) { + return summary.reasonTemplate + ? t(summary.reasonTemplate, summary.reasonValues) + : translateKnown(summary.reason || ''); +} + export function createPanel({ state: layerState, services, parts, source }) { function ensurePanel() { if (layerState.panel) return layerState.panel; @@ -71,7 +98,7 @@ export function createPanel({ state: layerState, services, parts, source }) { function rowHtml(cohort) { const summary = cohort.summary; - const count = formatAwarenessCount(summary); + const count = presentAwarenessCount(summary); const page = layerState.cohortPages.get(cohort.id) || 0; const nearest = summary.nearest .slice(page, page + AWARENESS_PAGE_SIZE) @@ -79,10 +106,10 @@ export function createPanel({ state: layerState, services, parts, source }) { const label = formatAwarenessLabel(item); const targetId = item.icao24 || item.mmsi || item.id; if (!targetId) { - return `
  • ${escapeHtml(label)} ${formatAwarenessDistance(item.distanceM)}
  • `; + return `
  • ${escapeHtml(label)} ${presentAwarenessDistance(item.distanceM)}
  • `; } const accessibleLabel = label === '—' ? t('Unavailable') : label; - return `
  • ${namedAreasHtml(item.memberNames)}
  • `; + return `
  • ${namedAreasHtml(item.memberNames)}
  • `; }) .join(''); const pageCount = Math.max( @@ -96,7 +123,7 @@ export function createPanel({ state: layerState, services, parts, source }) { const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : ''; return `
    ${escapeHtml(cohort.label)}${count}${pageLabel}
    - ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(summary.reason)} + ${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))} ${nearest ? `` : ''}
    `; } @@ -176,7 +203,7 @@ export function createPanel({ state: layerState, services, parts, source }) { function namedAreasHtml(names) { return names?.length - ? `
    Named areas (${names.length})
    ${names.map((name) => escapeHtml(name)).join('
    ')}
    ` + ? `
    ${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}
    ${names.map((name) => escapeHtml(name)).join('
    ')}
    ` : ''; } @@ -184,7 +211,7 @@ export function createPanel({ state: layerState, services, parts, source }) { if (!layerState.enabled || !layerState.results) return hidePanel(); const panel = ensurePanel(); const { subject, cohorts } = layerState.results; - const markup = `
    ${escapeHtml(subject.label)} · ${formatAwarenessDistance(AWARENESS_RADIUS_M)} FLIGHT / VESSEL WINDOW
    + const markup = `
    ${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}
    ${namedAreasHtml(subject.memberNames)} ${navigationControlsHtml()} ${cohorts.map(rowHtml).join('')} diff --git a/src/layers/awareness/queries.js b/src/layers/awareness/queries.js index 2a9c8d9e8..ad2568175 100644 --- a/src/layers/awareness/queries.js +++ b/src/layers/awareness/queries.js @@ -1,4 +1,17 @@ import { AWARENESS_RADIUS_M } from '../../data/militaryAwarenessEngine.js'; + +// The reason stays English for the voice analyst; the Contacts panel shows it +// in the UI language from `reasonTemplate`. Kept local so this module stays +// free of the i18n import. +const msg = (text) => text; +const fillTemplate = (template, values = {}) => + template.replace(/\{(\w+)\}/g, (_, name) => String(values[name])); +const withReason = (summary, template, values) => ({ + ...summary, + reason: fillTemplate(template, values), + reasonTemplate: template, + reasonValues: values, +}); import { DEPENDENCIES, AWARENESS_QUERY_LIMIT } from './policy.js'; export function createQueries({ state: layerState, services, parts, source }) { @@ -119,19 +132,20 @@ export function createQueries({ state: layerState, services, parts, source }) { // A subject window is its own bounded area: name it instead of the viewport. if (coverage?.kind === 'subject' && Number.isFinite(coverage.radiusM)) { const km = Math.round(coverage.radiusM / 1000); - return { - ...summary, - reason: summary.count - ? `mapped matches within ${km} km of the subject` - : `none mapped within ${km} km; not a complete 250 km survey`, - }; + return withReason( + summary, + summary.count + ? msg('mapped matches within {km} km of the subject') + : msg('none mapped within {km} km; not a complete 250 km survey'), + { km }, + ); } - return { - ...summary, - reason: summary.count - ? 'mapped matches from the loaded viewport' - : 'viewport feed is not a complete 250 km survey', - }; + return withReason( + summary, + summary.count + ? msg('mapped matches from the loaded viewport') + : msg('viewport feed is not a complete 250 km survey'), + ); } /**