mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
i18n: fix review findings and polish ru/es strings
Fixes the 51 P0–P2 findings from the full review of the i18n branch and the issues found while checking every new string on screen in ru and es. - Translate errors and statuses that still reached ru/es in English: feed and tile errors, provider statuses, the ALPR chip, the traffic mode chip, voice plan step labels, fire feed values, receiver bands, scene save reasons and the Cesium attribution link. - Show preset city names in the UI language (src/ui/placeNames.js); data and everything sent to the model stay English. - Share layer names between the layer panel, HUD and voice card (src/ui/layerNames.js). - Keep long ru/es strings whole: voice card error lines, analyst titles, the voice tier status, the Nepal evidence button, the ALPR retry chip. English layout is unchanged. - Ask before a language switch drops unsaved work. - Catalog fixes: glossary terms (пуск, пресет), gender agreement and consistent "peticiones" in es, fewer calques in both languages. - Fix English text that was itself wrong (e.g. "Exit cockpit to fly to a weather", raw tool ids in voice plan steps). Catalogs: es/ru 2535 messages each, complete. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8029747b7e
commit
00704ddc89
212 files changed
+9871
-909
No files matched your search
+310
-11
@@ -9,11 +9,26 @@
|
||||
* referents the card lists (the referent registry reads the same answer, so
|
||||
* "the second one" always means the second row on screen).
|
||||
*
|
||||
* What the model receives stays English (`analystHeadline` feeds the spoken
|
||||
* answer); the card shows the same answer in the active language.
|
||||
*
|
||||
* Pure: no DOM, no Cesium.
|
||||
* @module voice/resultDisplay
|
||||
*/
|
||||
|
||||
import { voiceLayer } from './layerManifest.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
t,
|
||||
tn,
|
||||
translateKnown,
|
||||
} from '../i18n/index.js';
|
||||
import { layerNameForId, sourceLabel } from '../ui/layerNames.js';
|
||||
import { localizePlaceName } from '../ui/placeNames.js';
|
||||
import { formatQuantity } from '../ui/unitLabels.js';
|
||||
|
||||
const LAYER_NOUNS = Object.freeze({
|
||||
flights: 'aircraft',
|
||||
@@ -52,13 +67,17 @@ export function analystNounFor(layerKeys) {
|
||||
|
||||
/** The count/scope headline shared by the analyst card and spoken answer. */
|
||||
const ANALYST_HEADLINE_MAX = 64;
|
||||
// The card's headline wraps, and a translation runs longer than the English:
|
||||
// "Al menos 12 aeronaves militares en un radio de 40 km de Los Ángeles" is 67.
|
||||
// English keeps the spoken bound, so its card title still equals `say`.
|
||||
const SHOWN_HEADLINE_MAX = 120;
|
||||
|
||||
function boundedHeadline(value) {
|
||||
function boundedHeadline(value, max = ANALYST_HEADLINE_MAX) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (text.length <= ANALYST_HEADLINE_MAX) return text;
|
||||
return `${text.slice(0, ANALYST_HEADLINE_MAX - 1).trimEnd()}…`;
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, max - 1).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function analystHeadline(result) {
|
||||
@@ -72,6 +91,282 @@ export function analystHeadline(result) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- the analyst card in the active language ---------------- */
|
||||
|
||||
/**
|
||||
* The count and its noun as the card shows them. The English matches
|
||||
* `analystNounFor`, one form for any count as the spoken headline has it.
|
||||
*/
|
||||
function shownCount(count, noun) {
|
||||
const values = { n: formatNumber(count) };
|
||||
switch (noun) {
|
||||
case 'aircraft':
|
||||
return tn(count, '{n} aircraft', '{n} aircraft', values);
|
||||
case 'military aircraft':
|
||||
return tn(
|
||||
count,
|
||||
'{n} military aircraft',
|
||||
'{n} military aircraft',
|
||||
values,
|
||||
);
|
||||
case 'ships':
|
||||
return tn(count, '{n} ships', '{n} ships', values);
|
||||
case 'satellites':
|
||||
return tn(count, '{n} satellites', '{n} satellites', values);
|
||||
case 'launches':
|
||||
return tn(count, '{n} launches', '{n} launches', values);
|
||||
case 'earthquakes':
|
||||
return tn(count, '{n} earthquakes', '{n} earthquakes', values);
|
||||
case 'fires':
|
||||
return tn(count, '{n} fires', '{n} fires', values);
|
||||
case 'fire perimeters':
|
||||
return tn(count, '{n} fire perimeters', '{n} fire perimeters', values);
|
||||
case 'cyclones':
|
||||
return tn(count, '{n} cyclones', '{n} cyclones', values);
|
||||
case 'buses':
|
||||
return tn(count, '{n} buses', '{n} buses', values);
|
||||
case 'bike share':
|
||||
return tn(count, '{n} bike share', '{n} bike share', values);
|
||||
case 'flock cameras':
|
||||
return tn(count, '{n} flock cameras', '{n} flock cameras', values);
|
||||
case 'military bases':
|
||||
return tn(count, '{n} military bases', '{n} military bases', values);
|
||||
case 'datacenters':
|
||||
return tn(count, '{n} datacenters', '{n} datacenters', values);
|
||||
case 'dams':
|
||||
return tn(count, '{n} dams', '{n} dams', values);
|
||||
case 'results':
|
||||
return tn(count, '{n} results', '{n} results', values);
|
||||
default:
|
||||
return `${values.n} ${noun}`;
|
||||
}
|
||||
}
|
||||
|
||||
const shownRadius = (km) => formatQuantity(Number(km), 'km');
|
||||
|
||||
const FIXED_SCOPES = new Map(
|
||||
[msg('anywhere in the loaded data'), msg('in view')].map((text) => [
|
||||
text,
|
||||
text,
|
||||
]),
|
||||
);
|
||||
|
||||
/**
|
||||
* The engine's scope phrases (analystEngine, the Contacts window in
|
||||
* gevActions) as the card shows them. The English is exactly the engine's;
|
||||
* a phrase not listed shows as given.
|
||||
*/
|
||||
const SCOPE_PATTERNS = [
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km of the view centre$/,
|
||||
([, km]) =>
|
||||
t('within {radius} of the view centre', { radius: shownRadius(km) }),
|
||||
],
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km of (.+) \(Contacts window\)$/,
|
||||
([, km, place]) =>
|
||||
t('within {radius} of {place} (Contacts window)', {
|
||||
radius: shownRadius(km),
|
||||
place: localizePlaceName(place),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km of (.+)$/,
|
||||
([, km, place]) =>
|
||||
t('within {radius} of {place}', {
|
||||
radius: shownRadius(km),
|
||||
place: localizePlaceName(place),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^within (\d+(?:\.\d+)?) km$/,
|
||||
([, km]) => t('within {radius}', { radius: shownRadius(km) }),
|
||||
],
|
||||
[/^over (.+)$/, ([, region]) => t('over {region}', { region })],
|
||||
];
|
||||
|
||||
function shownScope(text) {
|
||||
const scope = String(text || '').trim();
|
||||
if (!scope) return '';
|
||||
if (FIXED_SCOPES.has(scope)) return t(FIXED_SCOPES.get(scope));
|
||||
for (const [pattern, show] of SCOPE_PATTERNS) {
|
||||
const match = pattern.exec(scope);
|
||||
if (match) return show(match);
|
||||
}
|
||||
return translateKnown(scope);
|
||||
}
|
||||
|
||||
/** The analyst headline in the active language, for the card. */
|
||||
function shownAnalystHeadline(result) {
|
||||
const keys = (result?.coverage?.layersQueried || []).map(
|
||||
(layer) => layer.layerKey,
|
||||
);
|
||||
const count = Number(result?.count) || 0;
|
||||
const counted = shownCount(count, analystNounFor(keys));
|
||||
const items =
|
||||
result?.complete === false
|
||||
? t('At least {items}', { items: counted })
|
||||
: counted;
|
||||
const scope = shownScope(result?.scopeLabel);
|
||||
return boundedHeadline(
|
||||
scope ? t('{items} {scope}', { items, scope }) : items,
|
||||
getLocale() === 'en' ? ANALYST_HEADLINE_MAX : SHOWN_HEADLINE_MAX,
|
||||
);
|
||||
}
|
||||
|
||||
/** A layer key as the card names it. */
|
||||
function shownLayerKey(key) {
|
||||
const text = String(key || '').trim();
|
||||
return layerNameForId(text) || text;
|
||||
}
|
||||
|
||||
const shownLayerKeys = (list) =>
|
||||
String(list || '')
|
||||
.split(/,\s*/)
|
||||
.map(shownLayerKey)
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
/** The feed windows layers declare in the voice manifest. */
|
||||
const FIXED_WINDOWS = new Map(
|
||||
[msg('last 24 h'), msg('past 30 d and upcoming')].map((text) => [text, text]),
|
||||
);
|
||||
|
||||
function shownWindow(text) {
|
||||
return String(text || '')
|
||||
.split(/;\s*/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) =>
|
||||
FIXED_WINDOWS.has(part)
|
||||
? t(FIXED_WINDOWS.get(part))
|
||||
: translateKnown(part),
|
||||
)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
const UTC_CLOCK = Object.freeze({
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const UTC_DATE = Object.freeze({
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* The snapshot time the note carries (ISO, as the model got it) as the card
|
||||
* shows it: "14:32:05 UTC", with the date first ("15.10.2026, 14:32:05 UTC")
|
||||
* when the snapshot is not from today (UTC). English shows the ISO time as
|
||||
* before; a value that is not a time shows as written.
|
||||
*/
|
||||
export function shownSnapshotTime(iso, now = Date.now()) {
|
||||
const time = Date.parse(iso);
|
||||
if (getLocale() === 'en' || !Number.isFinite(time)) return iso;
|
||||
const clock = `${formatDate(time, UTC_CLOCK)} UTC`;
|
||||
const today = new Date(now).toISOString().slice(0, 10);
|
||||
return new Date(time).toISOString().slice(0, 10) === today
|
||||
? clock
|
||||
: `${formatDate(time, UTC_DATE)}, ${clock}`;
|
||||
}
|
||||
|
||||
/** Coverage notes and caveats the engine and the action runner write. */
|
||||
const FIXED_NOTES = new Map(
|
||||
[
|
||||
msg('the flights layer loads by viewport'),
|
||||
msg('distance is ground distance, not overhead'),
|
||||
msg(
|
||||
'the count is a lower bound because at least one layer reached its retained limit.',
|
||||
),
|
||||
].map((text) => [text, text]),
|
||||
);
|
||||
|
||||
const LAYER_STATUS_NOTES = Object.freeze({
|
||||
off: (label) => t('{label} off', { label }),
|
||||
loading: (label) => t('{label} loading', { label }),
|
||||
error: (label) => t('{label} error', { label }),
|
||||
});
|
||||
|
||||
const NOTE_PATTERNS = [
|
||||
[
|
||||
/^counted the first ([\d,]+) loaded records$/,
|
||||
([, returned]) =>
|
||||
t('counted the first {returned} loaded records', {
|
||||
returned: formatNumber(Number(returned.replace(/,/g, ''))),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^counted ([\d,]+) of ([\d,]+) loaded records$/,
|
||||
([, returned, total]) =>
|
||||
t('counted {returned} of {total} loaded records', {
|
||||
returned: formatNumber(Number(returned.replace(/,/g, ''))),
|
||||
total: formatNumber(Number(total.replace(/,/g, ''))),
|
||||
}),
|
||||
],
|
||||
[
|
||||
/^(.+) just turned on — still loading, counts will rise$/,
|
||||
([, layers]) =>
|
||||
t('{layers} just turned on — still loading, counts will rise', {
|
||||
layers: shownLayerKeys(layers),
|
||||
}),
|
||||
],
|
||||
[
|
||||
// The note ends in a period unless a lower-bound clause follows it.
|
||||
/^Contacts panel snapshot(?: at (\S+))? — the same retained cohort supplies the card, speech, and follow-up memory(\.?)$/,
|
||||
([, time, period]) =>
|
||||
(time
|
||||
? t(
|
||||
'Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||
{ time: shownSnapshotTime(time) },
|
||||
)
|
||||
: t(
|
||||
'Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory',
|
||||
)) + period,
|
||||
],
|
||||
[
|
||||
/^([a-z0-9-]+) (off|loading|error)$/,
|
||||
([, key, status]) =>
|
||||
layerNameForId(key)
|
||||
? LAYER_STATUS_NOTES[status](shownLayerKey(key))
|
||||
: null,
|
||||
],
|
||||
[
|
||||
/^([a-z0-9-]+): (.+)$/,
|
||||
([, key, note]) =>
|
||||
layerNameForId(key) ? `${shownLayerKey(key)}: ${shownNote(note)}` : null,
|
||||
],
|
||||
];
|
||||
|
||||
function shownNote(text) {
|
||||
if (FIXED_NOTES.has(text)) return t(FIXED_NOTES.get(text));
|
||||
for (const [pattern, show] of NOTE_PATTERNS) {
|
||||
const match = pattern.exec(text);
|
||||
const shown = match ? show(match) : null;
|
||||
if (shown) return shown;
|
||||
}
|
||||
return translateKnown(text);
|
||||
}
|
||||
|
||||
/** Feed states as the card's chips show them; the values stay English. */
|
||||
const FEED_STATE_LABELS = Object.freeze({
|
||||
loading: msg('loading'),
|
||||
degraded: msg('degraded'),
|
||||
stale: msg('stale'),
|
||||
partial: msg('partial'),
|
||||
fallback: msg('fallback'),
|
||||
unavailable: msg('unavailable'),
|
||||
});
|
||||
|
||||
function shownFeedState(state) {
|
||||
const key = FEED_STATE_LABELS[state];
|
||||
return key ? t(key) : state;
|
||||
}
|
||||
|
||||
function analystCard(result) {
|
||||
if (!result?.ok) return null;
|
||||
const display = result.display || {};
|
||||
@@ -91,28 +386,32 @@ function analystCard(result) {
|
||||
].map((label) => ({ label }));
|
||||
return {
|
||||
display: {
|
||||
title: analystHeadline(result),
|
||||
title: shownAnalystHeadline(result),
|
||||
// The precise scope, unless the title already says exactly that.
|
||||
lines: [
|
||||
display.scope !== result.scopeLabel ? display.scope : null,
|
||||
display.window,
|
||||
display.scope !== result.scopeLabel ? shownScope(display.scope) : null,
|
||||
shownWindow(display.window),
|
||||
].filter(Boolean),
|
||||
chips: [
|
||||
...(result.feedState && result.feedState !== 'nominal'
|
||||
? [{ label: result.feedState }]
|
||||
? [{ label: shownFeedState(result.feedState) }]
|
||||
: []),
|
||||
...(result.partial ? [{ label: 'partial' }] : []),
|
||||
...(result.partial ? [{ label: t('partial') }] : []),
|
||||
],
|
||||
// Coverage qualifications lead the feed detail. The bounded card groups
|
||||
// overflow in its existing Notes disclosure rather than dropping them.
|
||||
preserveNotes: true,
|
||||
notes: [
|
||||
...notesOf(result.coverage?.note, display.caveat),
|
||||
...notesOf(result.coverage?.note, display.caveat).map(shownNote),
|
||||
...(unanswered.length
|
||||
? [`Not answered: ${unanswered.join(', ')}`]
|
||||
? [
|
||||
t('Not answered: {layers}', {
|
||||
layers: unanswered.map(shownLayerKey).join(', '),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
sources,
|
||||
sources: sources.map(({ label }) => ({ label: sourceLabel(label) })),
|
||||
},
|
||||
referents: Array.isArray(result.items) ? result.items : [],
|
||||
resultSet: true,
|
||||
|
||||
Reference in new issue
Block a user