feat(i18n): translate the whole interface

Mark every UI string the unmarked-text check reported (534) plus text it
cannot see (helpers, joined labels, runtime-built panels), and translate
the result to Spanish and Russian: 1,679 messages each.

- Kept on purpose, with the reason where the text lives: provider and
  product names, place and camera names, codes and units, dB values, the HUD
  terminal readouts, demo shot titles that saved projects look up by title,
  and text for AI tools and the voice model.
- Modules shared with the server or AI tools stay free of the i18n import
  (local msg); the wind reading and voice session stay English and are
  translated where the page shows them.
- npm test now runs the unmarked-text check.
- Fixes on the way: a duplicated radio privacy paragraph, the contact panel
  toggle saying Expand while open, the CSS filter mistaking & for CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-12 19:24:42 +04:00
1 parent ee72133574
commit 3129e531e6
99 files changed
+4670 -1294

No files matched your search

+3 -3
View File
@@ -3,14 +3,14 @@ import { normalizeLongitude, sampleWind } from './model.js';
/** Fixed physical scales shared by imagery and the field legend. */
export const WIND_FIELDS = Object.freeze({
speed: Object.freeze({
label: 'Wind speed',
label: 'Wind speed', // i18n-ignore: internal field name; the shown label comes from index.js
units: 'm/s',
min: 0,
max: 30,
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
}),
temperature: Object.freeze({
label: '2 m air temperature',
label: '2 m air temperature', // i18n-ignore: internal field name; the shown label comes from index.js
units: '°C',
min: -40,
max: 50,
@@ -30,7 +30,7 @@ export const WIND_FIELDS = Object.freeze({
],
}),
pressure: Object.freeze({
label: 'Mean sea-level pressure',
label: 'Mean sea-level pressure', // i18n-ignore: internal field name; the shown label comes from index.js
units: 'hPa',
min: 960,
max: 1050,
+48 -37
View File
@@ -11,6 +11,7 @@ import {
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { t } from '../../i18n/index.js';
/** Format a forecast timestamp explicitly in UTC. */
export function formatWindValidTime(value) {
@@ -242,7 +243,7 @@ export function createWindLayer({
getRowControls() {
const historyStatus =
clock?.getState().mode === 'history'
? 'Forecast · does not follow history'
? t('Forecast · does not follow history')
: null;
const valid = formatWindValidTime(manifest?.cycle?.validIso);
const run = formatWindValidTime(manifest?.cycle?.runIso);
@@ -261,10 +262,10 @@ export function createWindLayer({
const imageryError =
enabled && !loading ? diagnostic?.imageryError : null;
const label = {
none: 'Wind motion',
speed: 'Wind speed',
pressure: 'Sea-level pressure',
temperature: 'Air temperature · 2 m',
none: t('Wind motion'),
speed: t('Wind speed'),
pressure: t('Sea-level pressure'),
temperature: t('Air temperature · 2 m'),
}[overlay];
const kind = ['temperature', 'pressure'].includes(overlay)
? overlay
@@ -290,60 +291,64 @@ export function createWindLayer({
readout: true,
summary: {
label,
coverage: 'Global · 1° grid',
coverage: t('Global · 1° grid'),
validTime: manifest?.cycle?.validIso,
issuedTime: manifest?.cycle?.runIso,
detail: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${valid || 'Unavailable'}`,
detail: t('{model} forecast · {time}', {
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
time: valid || t('Unavailable'),
}),
status: loading
? 'Loading forecast'
? t('Loading forecast')
: preparing
? 'Preparing flow'
? t('Preparing flow')
: error ||
manifest?.reason ||
imageryError ||
(scalarMissing ? 'Selected field unavailable' : null) ||
(manifest?.stale ? 'Cached forecast · stale' : null),
(scalarMissing ? t('Selected field unavailable') : null) ||
(manifest?.stale ? t('Cached forecast · stale') : null),
units: legendUnit,
},
chips: [
...['gfs', 'ifs'].map((value) => ({
id: `model-${value}`,
label: value === 'ifs' ? 'ECMWF' : 'GFS',
label: value === 'ifs' ? 'ECMWF' : 'GFS', // i18n-ignore: forecast model names
active: model === value,
params: { model: value },
title:
value === 'ifs'
? 'ECMWF IFS surface forecast'
: 'NOAA GFS surface forecast',
? t('ECMWF IFS surface forecast')
: t('NOAA GFS surface forecast'),
})),
{
id: 'motion',
label: diagnostic?.reducedMotion
? 'Reduced motion'
? t('Reduced motion')
: paused
? 'Resume'
: 'Pause',
? t('Resume')
: t('Pause'),
active: motionPaused,
disabled: Boolean(diagnostic?.reducedMotion),
params: { paused: !paused },
title:
title: t(
'Pause visual flow; forecast time does not advance with animation',
),
},
...['none', 'speed', 'pressure', 'temperature'].map((value) => ({
id: `overlay-${value}`,
label: {
none: 'None',
speed: 'Speed',
pressure: 'Pressure',
temperature: 'Temperature',
none: t('None'),
speed: t('Speed'),
pressure: t('Pressure'),
temperature: t('Temperature'),
}[value],
active: overlay === value,
params: { overlay: value },
title: {
none: 'Wind trails with no field shading',
speed: 'How hard the surface wind is blowing',
pressure: 'Sea-level air pressure: broad highs and lows',
temperature: 'Air temperature two meters above the surface',
none: t('Wind trails with no field shading'),
speed: t('How hard the surface wind is blowing'),
pressure: t('Sea-level air pressure: broad highs and lows'),
temperature: t('Air temperature two meters above the surface'),
}[value],
})),
...(speedLegendVisible ||
@@ -356,15 +361,16 @@ export function createWindLayer({
label: value,
active: units === value,
params: { units: value },
title: 'Wind speed units',
title: t('Wind speed units'),
})),
{
id: 'read-wind',
label: 'Read wind at map center',
label: t('Read wind at map center'),
params: { inspect: true },
disabled: loading || !manifest?.u || manifest?.unavailable,
title:
title: t(
'Read the forecast at the center of the map without changing selection',
),
},
],
legend:
@@ -373,9 +379,14 @@ export function createWindLayer({
(overlay === 'none' && !speedLegendVisible)
? []
: legend,
info: `${model === 'ifs' ? 'ECMWF IFS' : 'GFS'} forecast · ${label} (${legendUnit})${historyStatus ? `\n${historyStatus}` : ''}\nValid: ${valid || 'Unavailable'}${loading ? ' · loading' : preparing ? ' · preparing' : ''}\nIssued: ${run || 'Unavailable'}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? '\nSelected field unavailable · wind remains visible' : ''}${imageryError && !scalarMissing ? '\n' + imageryError + ' · wind remains visible' : ''}`,
infoTitle:
info: `${t('{model} forecast · {field} ({unit})', {
model: model === 'ifs' ? 'ECMWF IFS' : 'GFS',
field: label,
unit: legendUnit,
})}${historyStatus ? `\n${historyStatus}` : ''}\n${t('Valid: {time}', { time: valid || t('Unavailable') })}${loading ? ` · ${t('loading')}` : preparing ? ` · ${t('preparing')}` : ''}\n${t('Issued: {time}', { time: run || t('Unavailable') })}${manifest?.stale ? ' · STALE' : ''}${error ? '\n' + error : ''}${scalarMissing ? `\n${t('Selected field unavailable · wind remains visible')}` : ''}${imageryError && !scalarMissing ? `\n${t('{error} · wind remains visible', { error: imageryError })}` : ''}`,
infoTitle: t(
'Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.',
),
};
controls.summary.reading = reading;
controls.summary.result = reading ? windReadingResult(reading) : null;
@@ -385,12 +396,12 @@ export function createWindLayer({
chips: controls.chips.filter((chip) => chip.id.startsWith(prefix)),
});
controls.summary.settings = [
setting('model', 'MODEL', 'model-'),
setting('field', 'FIELD', 'overlay-'),
setting('model', t('MODEL'), 'model-'),
setting('field', t('FIELD'), 'overlay-'),
...(controls.chips.some(({ id }) => id.startsWith('units-'))
? [{ id: 'units', label: 'UNITS', chips: windUnitChips(units) }]
? [{ id: 'units', label: t('UNITS'), chips: windUnitChips(units) }]
: []),
setting('motion', 'MOTION', 'motion'),
setting('motion', t('MOTION'), 'motion'),
];
controls.summary.actions = controls.chips.filter(
({ id }) => id === 'read-wind',
@@ -425,7 +436,7 @@ export function createWindLayer({
: null;
return {
...windStats(manifest),
countLabel: 'Forecast',
countLabel: t('Forecast'),
loading: loading || Boolean(preparing),
model: model.toUpperCase(),
overlay,
@@ -433,7 +444,7 @@ export function createWindLayer({
stale: Boolean(manifest?.stale),
source: model === 'ifs' ? 'ECMWF IFS' : 'NOAA GFS',
validTime:
formatWindValidTime(manifest?.cycle?.validIso) || 'Unavailable',
formatWindValidTime(manifest?.cycle?.validIso) || t('Unavailable'),
error:
error ||
windStats(manifest).error ||
+27 -22
View File
@@ -1,23 +1,27 @@
import { sampleWind, windSpeed } from './model.js';
import { sampleScalar } from './fields.js';
// The reading stays English: AI tools read it too. Text is marked for i18n
// extraction and translated where the WEATHER card shows it (presentation.js).
const msg = (text) => text;
const DIRECTIONS = [
'N',
'NNE',
'NE',
'ENE',
'E',
'ESE',
'SE',
'SSE',
'S',
'SSW',
'SW',
'WSW',
'W',
'WNW',
'NW',
'NNW',
msg('N'),
msg('NNE'),
msg('NE'),
msg('ENE'),
msg('E'),
msg('ESE'),
msg('SE'),
msg('SSE'),
msg('S'),
msg('SSW'),
msg('SW'),
msg('WSW'),
msg('W'),
msg('WNW'),
msg('NW'),
msg('NNW'),
];
export const WIND_UNITS = Object.freeze({
'km/h': 3.6,
@@ -56,8 +60,8 @@ export function inspectWindAtCenter(
));
if (!point || !snapshot?.u || !snapshot?.v)
return {
coordinates: 'No surface reading',
wind: 'Aim the map center at Earth',
coordinates: msg('No surface reading'),
wind: msg('Aim the map center at Earth'),
model,
validTime,
status,
@@ -87,11 +91,11 @@ export function inspectWindAtCenter(
scalarKind: overlay,
scalarLabel:
overlay === 'speed'
? 'Wind speed'
? msg('Wind speed')
: overlay === 'temperature'
? 'Air temperature · 2 m'
? msg('Air temperature · 2 m')
: overlay === 'pressure'
? 'Sea-level pressure'
? msg('Sea-level pressure')
: null,
scalarValue:
overlay === 'speed'
@@ -102,8 +106,9 @@ export function inspectWindAtCenter(
model,
validTime,
status,
explanation:
explanation: msg(
'Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.',
),
};
}
+26 -6
View File
@@ -1,4 +1,5 @@
import { formatWindSpeed, WIND_UNITS } from './inspection.js';
import { t } from '../../i18n/index.js';
/** Reformat a captured sample; changing units never samples the map again. */
export function formatWindReading(reading, units) {
@@ -22,33 +23,52 @@ export function windUnitChips(units) {
label: value,
active: units === value,
params: { units: value },
title: 'Wind speed units',
title: t('Wind speed units'),
}));
}
/** The wind line in the active language; the reading itself stays English. */
function windText(reading) {
if (!Number.isFinite(reading.speed))
return reading.wind === 'Aim the map center at Earth'
? t('Aim the map center at Earth')
: reading.wind;
const speed = formatWindSpeed(reading.speed, reading.units);
return reading.from === 'Calm'
? `${speed}${t(' · calm')}`
: `${speed}${t(' from {direction}', { direction: t(reading.from) })}`;
}
/** Portable result block consumed by the WEATHER card. */
export function windReadingResult(reading) {
const coordinates =
reading.coordinates === 'No surface reading'
? t('No surface reading')
: reading.coordinates.replace(' · ', ' ');
return {
id: 'reading',
label: `WIND AT ${reading.coordinates.replace(' · ', ' ')}`,
label: t('WIND AT {coordinates}', { coordinates }),
lines: [
{
id: 'wind',
text: reading.wind,
text: windText(reading),
},
{
id: 'meta',
text: `${reading.model} · valid ${reading.validTime?.replace(/^\d{4}-/, '')}`,
text: t('{model} · valid {time}', {
model: reading.model,
time: reading.validTime?.replace(/^\d{4}-/, ''),
}),
},
...(reading.scalarValue
? [
{
id: 'scalar',
text: `${reading.scalarLabel} · ${reading.scalarValue}`,
text: `${t(reading.scalarLabel)} · ${reading.scalarValue}`,
},
]
: []),
{ id: 'explanation', text: reading.explanation },
{ id: 'explanation', text: t(reading.explanation) },
],
clear: { params: { inspect: false } },
};