From 896952c08532b89d955116bc2caac4d9a1f7d362 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 13 Oct 2026 23:36:30 +0400 Subject: [PATCH] fix(i18n): Contacts panel text and distances in the UI language The Contacts panel builds its markup in a variable before innerHTML, so the unmarked-text check missed it: the window caption, "Named areas", the "at least" count and each cohort's reason stayed English, and distances read "4.6 km" in every language. The reason stays English in the cohort summary, which the voice analyst reads too; it now also carries its template, and the panel shows it in the UI language. Distances and counts use the locale's number format and unit. Co-Authored-By: Claude Opus 5.5 --- scripts/package-boundaries.json | 3 ++- src/i18n/locales/es.json | 9 ++++++- src/i18n/locales/ru.json | 9 ++++++- src/layers/awareness/panel.js | 43 +++++++++++++++++++++++++++------ src/layers/awareness/queries.js | 38 ++++++++++++++++++++--------- 5 files changed, 79 insertions(+), 23 deletions(-) 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 ? `
      ${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'), + ); } /**