fix(i18n): Spanish review fixes; report translations left in English

A screenshot review of the Spanish UI found:

- Text left in English. Catalog entries copied from English (Bloom,
  Cyber, Pin, NEWS, SITE, ON/OFF, RANGE, PITCH, HGT…) and the radio
  music genres, which never went through t(). The genres are translated
  in es and ru now.
- Wording: gender agreement, calques of the English, hints that name
  buttons that do not exist, and one concept worded two ways.
- Layout: the DESACTIVADO pill squeezed layer names until words broke
  mid-word; the scene and camera selects showed one word; a collapsed
  rail title pushed its button out of the card; the image slot line and
  a comparison readout were cut or split.

The layout fixes are scoped to languages other than English, so English
stays pixel-identical. Visual-style slider values now use the locale's
decimal separator (0,85).

npm run i18n now reports a translation that is identical to the
English, unless src/i18n/same-as-english.json lists it for that language
(codes, product names, words spelled the same).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-13 21:53:57 +04:00
1 parent 9f1e409193
commit 7db0abc396
16 files changed
+407 -111

No files matched your search

+10 -7
View File
@@ -58,6 +58,8 @@ Changing the English changes the key, so the old translation goes unused and the
To see a language, open the app with `?lang=ru`, or use the language switch in the Visual Presets dock.
A translation that is exactly the English ("Bloom" → "Bloom") is reported as still English. When it is right on purpose, such as a code (HDG, CRT), a product name or a word spelled the same in that language, list it under the language in `src/i18n/same-as-english.json`.
## Text the check reports as unmarked
`npm run i18n` also lists UI text that is not marked at all, and fails while there is any; `npm test` runs the same check:
@@ -77,10 +79,11 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca
## Where things live
| File | What it holds |
| ------------------------------ | ------------------------------------------------------------------------------ |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
| File | What it holds |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `src/i18n/index.js` | `t`, `tn`, `msg`, the Intl formatters, markup translation, switching language |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
| `src/i18n/catalog.test.mjs` | runs that check under `npm test` |
+1 -1
View File
@@ -32,7 +32,7 @@ npm run i18n -- --missing ru # missing ru entries as JSON, ready to transla
npm run i18n -- --prune # drop entries the code no longer uses
```
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, or when a plural entry lacks a form.
`npm test` runs the same check (`src/i18n/catalog.test.mjs`). It fails when a translation is missing, when an entry is no longer used, when placeholders differ from the English, when a plural entry lacks a form, or when a translation is still the English text. If an entry is right as it is in English (a code such as HDG, a product name, a word spelled the same in your language), add it under your language code in `src/i18n/same-as-english.json`.
Changing English text changes its key: the old translation becomes unused and the new text shows as missing. Translate it and prune.
+29 -1
View File
@@ -4,6 +4,10 @@
// npm run i18n -- --prune also drop entries the code no longer uses
// npm run i18n -- --missing ru print what ru lacks, as JSON to translate
//
// A translation identical to the English is reported too, unless it is
// listed for that locale in src/i18n/same-as-english.json (codes, product
// names, words spelled the same in both languages).
//
// Messages are the English literals passed to t(), tn() and msg() in src/,
// plus the markup opted in with data-i18n* in src/ui/templates and index.html.
// It also lists UI text that is not marked at all (see findUnmarked).
@@ -14,6 +18,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const LOCALES_DIR = path.join(ROOT, 'src/i18n/locales');
const SAME_AS_ENGLISH = path.join(ROOT, 'src/i18n/same-as-english.json');
const STRING = String.raw`'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|\`(?:\\.|[^\`\\$])*\``;
const CALL = new RegExp(
@@ -424,11 +429,26 @@ export function shippedLocales() {
);
}
/** Messages a locale keeps exactly as in English, on purpose. */
export function readSameAsEnglish(locale) {
const lists = JSON.parse(readFileSync(SAME_AS_ENGLISH, 'utf8'));
return new Set(lists[locale] || []);
}
/** The English a message shows: its text without the "context|" prefix. */
const englishOf = (text) => text.slice(text.indexOf('|') + 1);
/**
* Compare one catalog with the extracted messages.
* @param {Set<string>} [sameAsEnglish] Messages allowed to read as English.
* @returns {{missing: string[], stale: string[], errors: string[]}}
*/
export function checkCatalog(locale, catalog, messages) {
export function checkCatalog(
locale,
catalog,
messages,
sameAsEnglish = readSameAsEnglish(locale),
) {
const categories = new Intl.PluralRules(locale).resolvedOptions()
.pluralCategories;
const missing = [];
@@ -456,6 +476,14 @@ export function checkCatalog(locale, catalog, messages) {
errors.push(`"${text}" must be a string`);
} else if (placeholders(entry) !== placeholders(text)) {
errors.push(`"${text}" placeholders differ: ${entry}`);
} else if (
entry === englishOf(text) &&
/\p{L}{2}/u.test(entry.replace(/\{[^}]*\}/g, '')) &&
!sameAsEnglish.has(text)
) {
errors.push(
`"${text}" is still English: translate it, or list it under "${locale}" in src/i18n/same-as-english.json`,
);
}
}
const stale = Object.keys(catalog).filter((text) => !messages.has(text));
+22
View File
@@ -66,6 +66,28 @@ test('checkCatalog reports missing, stale, placeholder and plural problems', ()
assert.equal(result.errors.length, 2);
});
test('checkCatalog reports a translation left in English unless it is allowed', () => {
const fixture = new Map([
['Bloom', { plural: false }],
['HDG', { plural: false }],
['voice|READY', { plural: false }],
['{state} · {source}', { plural: false }],
]);
const catalog = {
Bloom: 'Bloom',
HDG: 'HDG',
'voice|READY': 'READY',
'{state} · {source}': '{state} · {source}',
};
const errors = (allowed) =>
checkCatalog('es', catalog, fixture, new Set(allowed)).errors;
assert.deepEqual(
errors([]).map((error) => error.split(' is still English')[0]),
['"Bloom"', '"HDG"', '"voice|READY"'],
);
assert.deepEqual(errors(['Bloom', 'HDG', 'voice|READY']), []);
});
test('unmarkedMarkup finds text and attributes without data-i18n*', () => {
const texts = (html) => unmarkedMarkup('f.html', html).map((f) => f.text);
assert.deepEqual(texts('<span>Close</span>'), ['Close']);
+93 -67
View File
@@ -31,7 +31,7 @@
"RETRYING MAPPED SITES": "REINTENTANDO SITIOS MAPEADOS",
"FETCHING MAPPED SITES": "OBTENIENDO SITIOS MAPEADOS",
"TURNING OFF LIVE DATA": "DESACTIVANDO DATOS EN VIVO",
"REFRESHING LIVE DATA": "ACTUALIZANDO DATOS EN VIVO",
"REFRESHING LIVE DATA": "ACTUALIZANDO DATOS",
"Close data attribution": "Cerrar la atribución de datos",
"Aircraft cockpit view": "Vista de cabina de la aeronave",
"EXIT COCKPIT": "SALIR DE CABINA",
@@ -103,8 +103,8 @@
"Celestial": "Celeste",
"Hide UI chrome": "Ocultar los elementos de la interfaz",
"Clean UI": "Interfaz limpia",
"Bloom / Glow": "Bloom / Resplandor",
"Bloom": "Bloom",
"Bloom / Glow": "Resplandor",
"Bloom": "Resplandor",
"Sharpening": "Nitidez",
"Sharpen": "Nitidez",
"LOCATION": "UBICACIÓN",
@@ -222,8 +222,8 @@
"Collapse Radio section": "Contraer la sección de Radio",
"Searched location": "Ubicación buscada",
"Disable cockpit weather effects": "Desactivar los efectos climáticos de la cabina",
"ON": "ACTIVADO",
"OFF": "DESACTIVADO",
"ON": "ACTIVO",
"OFF": "INACTIVO",
"Restore real aircraft": "Restaurar la aeronave real",
"Night vision": "Visión nocturna",
"Thermal": "Térmica",
@@ -277,8 +277,8 @@
"Expand briefing panel": "Expandir el panel de información",
"CYCLE ON": "CICLO: SÍ",
"CYCLE OFF": "CICLO: NO",
"Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Detén el cambio automático de páginas. Anterior, Siguiente y las pestañas SIG/NEWS/LOCAL siguen disponibles.",
"REGIONAL NEWS": "NOTICIAS REGIONALES",
"Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Detén el cambio automático de páginas. Anterior, Siguiente y las pestañas SIG/NOTICIAS/LOCAL siguen disponibles.",
"REGIONAL NEWS": "NOTICIAS DE ZONA",
"LOCAL INFO": "INFORMACIÓN LOCAL",
"LATEST LOCATION-MATCHED REPORTING": "ÚLTIMOS REPORTES SEGÚN UBICACIÓN",
"PLACE / CONDITIONS / POSITION": "LUGAR / CLIMA / POSICIÓN",
@@ -358,9 +358,9 @@
"CAL · {badge}": "CAL · {badge}",
"CALIBRATED": "CALIBRADA",
"CURATED": "CURADA",
"RAW PRIOR": "PREVIA SIN PROCESAR",
"RAW PRIOR": "ESTIMACIÓN INICIAL",
"MONITOR": "MONITOR",
"{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · RANGE {range} m · {projection}{calBadge} · {provider}{status}",
"{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · DIST. {range} m · {projection}{calBadge} · {provider}{status}",
"{count} cameras loaded · click a camera to activate": {
"one": "{count} cámara cargada · haz clic en una cámara para activarla",
"many": "{count} de cámaras cargadas · haz clic en una cámara para activarla",
@@ -385,7 +385,7 @@
"Awaiting telemetry...": "Esperando telemetría...",
"REC": "REC",
"Scope edge feather": "Difuminado del borde de la mirilla",
"Bloom intensity": "Intensidad del bloom",
"Bloom intensity": "Intensidad del resplandor",
"Sharpen intensity": "Intensidad de la nitidez",
"Search location by name or coordinates": "Buscar ubicación por nombre o coordenadas",
"Expand LOCATION": "Expandir UBICACIÓN",
@@ -412,7 +412,7 @@
"CCTV camera": "Cámara CCTV",
"FOCUS": "ENFOCAR",
"COVERAGE OFF": "COBERTURA DESACTIVADA",
"AUTO HOP OFF": "AUTOSALTO DESACTIVADO",
"AUTO HOP OFF": "SALTO AUTOMÁTICO: NO",
"PROJECTION ON": "PROYECCIÓN ACTIVADA",
"CALIBRATION": "CALIBRACIÓN",
"ADJUST": "AJUSTAR",
@@ -435,7 +435,7 @@
"STATION TAG": "ETIQUETA DE EMISORA",
"Filter stations by station tag": "Filtrar emisoras por etiqueta de emisora",
"NO STATION SELECTED": "NINGUNA EMISORA SELECCIONADA",
"Enable Radio, then choose a globe marker or use next.": "Activa Radio y elige un marcador del globo o usa siguiente.",
"Enable Radio, then choose a globe marker or use next.": "Activa Radio y elige un marcador del globo o pulsa SIG.",
"DIRECTORY BAND": "BANDA DEL DIRECTORIO",
"DRAG TO TUNE": "ARRASTRA PARA SINTONIZAR",
"ALL · DRAG THE NEEDLE": "TODAS · ARRASTRA LA AGUJA",
@@ -450,7 +450,7 @@
"Radio volume": "Volumen de Radio",
"Radio off": "Radio desactivada",
"STATION SITE": "SITIO DE LA EMISORA",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora después de pulsar reproducir. Tu IP es visible para esa emisora.",
"Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.": "El audio se conecta directamente con la emisora después de pulsar ESCUCHAR. Tu IP es visible para esa emisora.",
"LOADING": "CARGANDO",
"DEGRADED": "DEGRADADO",
"STALE": "DESACTUALIZADO",
@@ -458,7 +458,7 @@
"PARTIAL": "PARCIAL",
"UNCERTAIN": "INCIERTO",
"ENABLING": "ACTIVANDO",
"DISABLING": "DESACTIVANDO",
"DISABLING": "APAGANDO",
"Military Flights": "Vuelos militares",
"Satellites": "Satélites",
"Space Missions (30d)": "Misiones espaciales (30d)",
@@ -476,7 +476,7 @@
"{count}s ago": "hace {count}s",
"{count}m ago": "hace {count} min",
"{count}h ago": "hace {count} h",
"never": "nunca",
"never": "sin actualizar",
"loading...": "cargando...",
"UNCERTAIN · {source} · lifecycle state requires reconciliation": "INCIERTO · {source} · el estado del ciclo de vida requiere reconciliación",
"{state} · {source}": "{state} · {source}",
@@ -517,11 +517,11 @@
"POS: {time}Z": "POS: {time}Z",
"AIS: --": "AIS: --",
"AIS: {name}": "AIS: {name}",
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
"awaiting first AIS position…": "esperando la primera posición AIS…",
"CCTV ON": "CCTV ACTIVADO",
"COVERAGE ON": "COBERTURA ACTIVADA",
"AUTO HOP ON": "AUTOSALTO ACTIVADO",
"AUTO HOP ON": "SALTO AUTOMÁTICO: SÍ",
"PROJECTION OFF": "PROYECCIÓN DESACTIVADA",
"ADJUST ON": "AJUSTE ACTIVADO",
"{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "{count} CÁMARAS EN ESPERA · NINGUNA CÁMARA SELECCIONADA · CLIC EN UNA CÁMARA PARA ACTIVARLA",
@@ -555,9 +555,9 @@
"Reconcile Radio — lifecycle uncertain": "Reconciliar Radio — ciclo de vida incierto",
"Loading station directory…": "Cargando el directorio de emisoras…",
"Directory metadata only": "Solo metadatos del directorio",
"Choose a globe marker or use next.": "Elige un marcador del globo o usa siguiente.",
"Choose a globe marker or use next.": "Elige un marcador del globo o pulsa SIG.",
"TAGS · {tags}": "ETIQUETAS · {tags}",
"Ready — playback starts only from your action": "Lista — la reproducción comienza solo con tu acción",
"Ready — playback starts only from your action": "Preparada: la reproducción empieza cuando pulses ESCUCHAR",
"Connecting directly to broadcaster…": "Conectando directamente con la emisora…",
"Buffering broadcaster stream…": "Almacenando en búfer el flujo de la emisora…",
"Playing {station}": "Reproduciendo {station}",
@@ -600,7 +600,7 @@
"Next mission": "Misión siguiente",
"SHOW ALL / DESELECT": "MOSTRAR TODAS / DESELECCIONAR",
"NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "SIN MISIONES DISPONIBLES EN LA VENTANA ACTUAL DE 30 DÍAS",
"{count} / 30D": "{count} / 30D",
"{count} / 30D": "{count} / 30 D",
"DATE UNAVAILABLE": "FECHA NO DISPONIBLE",
"UNSPECIFIED OPERATOR": "OPERADOR NO ESPECIFICADO",
"Select {mission}": "Seleccionar {mission}",
@@ -655,7 +655,7 @@
"COMMS": "TELECOM",
"Broadband constellation shell — shown only in DENSE mode": "Capa de constelación de banda ancha — visible solo en modo DENSO",
"refreshing...": "actualizando...",
"KEY REQUIRED": "CLAVE REQUERIDA",
"KEY REQUIRED": "CLAVE REQUERIDA",
"STALE · cached {age}": "DESACTUALIZADO · en caché hace {age}",
"LIVE · updated {age}": "EN VIVO · actualizado {age}",
"<1m ago": "hace <1 min",
@@ -663,7 +663,7 @@
"▲ {frp} MW": "▲ {frp} MW",
"FIRE": "INCENDIO",
"FIRES": "INCENDIOS",
"{value} conf": "{value} conf",
"{value} conf": "{value} de confianza",
"{value} ago": "hace {value}",
"max {value} MW": "máx {value} MW",
"new {value}": "nueva hace {value}",
@@ -748,7 +748,7 @@
"Close key setup": "Cerrar la configuración de claves",
"The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "El globo ya vuela sin claves. Cada clave de abajo activa otra fuente real — pega una y queda guardada en la configuración local de esta app, y luego el servidor se reinicia solo. Las claves del servidor se quedan en esta máquina; Google Maps y Cesium ion funcionan en el navegador y sus claves deben estar restringidas por el proveedor. Las claves que configuraste en otro lugar se muestran, pero nunca se modifican.",
"ESC to close": "ESC para cerrar",
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps compra el planeta fotorrealista — todo lo demás se monta encima.",
"The Google Maps key buys the photorealistic planet — everything else stacks on top.": "La clave de Google Maps activa el planeta fotorrealista; todo lo demás se superpone.",
"SCENES": "ESCENAS",
"Scene recipe": "Receta de escena",
"NEW": "NUEVA",
@@ -831,7 +831,7 @@
"Could not exit {mode} — scene layers may be refused": "No se pudo salir de {mode} — las capas de la escena pueden rechazarse",
"REMOVE": "ELIMINAR",
"Remove {title} from this app's saved keys": "Elimina {title} de las claves guardadas de esta app",
"Needs {envVars} — add it in Provider Settings": "Necesita {envVars} — añádela en Ajustes de proveedores",
"Needs {envVars} — add it in Provider Settings": "Necesita {envVars} — se añade en Ajustes de proveedores",
"Places context + Street View fallback; optional separate key": "Contexto de Places + respaldo de Street View; clave separada opcional",
"Live ships, worldwide": "Buques en vivo, en todo el mundo",
"Live active-fire detections": "Detecciones de incendios activos en vivo",
@@ -872,8 +872,8 @@
"WHOT/BHOT": "WHOT/BHOT",
"Ironbow": "Ironbow",
"Pick a shape, then click the map.": "Elige una forma y haz clic en el mapa.",
"Enter to place the pin, Esc to cancel.": "Enter para colocar el pin, Esc para cancelar.",
"Click where the pin goes.": "Haz clic donde va el pin.",
"Enter to place the pin, Esc to cancel.": "Enter para colocar el marcador, Esc para cancelar.",
"Click where the pin goes.": "Haz clic donde quieras colocar el marcador.",
"Click {count} more points.": {
"one": "Haz clic en {count} punto más.",
"many": "Haz clic en {count} de puntos más.",
@@ -892,7 +892,7 @@
"Shape to draw": "Forma a dibujar",
"Area": "Área",
"Line": "Línea",
"Pin": "Pin",
"Pin": "Marcador",
"Label (optional)": "Etiqueta (opcional)",
"Label for the drawn shape": "Etiqueta de la forma dibujada",
"Colour of the drawn shape": "Color de la forma dibujada",
@@ -910,7 +910,7 @@
"Bike Share": "Bicis compartidas",
"Cameras": "Cámaras",
"Street Level": "A pie de calle",
"Mapped ALPR Cameras": "Cámaras ALPR cartografiadas",
"Mapped ALPR Cameras": "Cámaras ALPR mapeadas",
"Data Centers": "Centros de datos",
"Active Fires": "Incendios activos",
"Street Level could not start": "No se pudo iniciar A pie de calle",
@@ -946,9 +946,9 @@
"Switch a provider on to draw its coverage.": "Activa un proveedor para dibujar su cobertura.",
"Loading this sequence…": "Cargando esta secuencia…",
"{n} images in this sequence · Esc clears": {
"one": "{n} imagen en esta secuencia · Esc la quita",
"many": "{n} de imágenes en esta secuencia · Esc la quita",
"other": "{n} imágenes en esta secuencia · Esc la quita"
"one": "{n} imagen en esta secuencia · Esc quita la selección",
"many": "{n} de imágenes en esta secuencia · Esc quita la selección",
"other": "{n} imágenes en esta secuencia · Esc quita la selección"
},
"{n} sequences in view · click a line for its photos": {
"one": "{n} secuencia a la vista · haz clic en una línea para ver sus fotos",
@@ -1038,7 +1038,7 @@
"Play reconstruction": "Reproducir la reconstrucción",
"Load previous scene shot": "Cargar el plano anterior de la escena",
"Load next scene shot": "Cargar el siguiente plano de la escena",
"SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "CORREDOR ESQUEMÁTICO, NO TIEMPO DE LLEGADA MODELADO.",
"SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "CORREDOR ESQUEMÁTICO; NO ES UN TIEMPO DE LLEGADA MODELADO.",
"Rasuwa · 26 Aug 2026": "Rasuwa · 26 ago 2026",
"Event reconstruction": "Reconstrucción del evento",
"INITIATION": "INICIO",
@@ -1047,8 +1047,8 @@
"BORDER ARRIVAL": "LLEGADA A LA FRONTERA",
"PROPAGATION": "PROPAGACIÓN",
"DOWNSTREAM PASSAGE": "PASO AGUAS ABAJO",
"CONSEQUENCE": "CONSECUENCIA",
"DOWNSTREAM CONSEQUENCE": "CONSECUENCIA AGUAS ABAJO",
"CONSEQUENCE": "SECUELAS",
"DOWNSTREAM CONSEQUENCE": "SECUELAS AGUAS ABAJO",
"EVIDENCE": "EVIDENCIA",
"{count} GEOLOCATED SOURCE-MAP PLACEMENTS": {
"one": "{count} UBICACIÓN GEOLOCALIZADA EN EL MAPA DE FUENTES",
@@ -1064,7 +1064,7 @@
"Close event layer": "Cerrar la capa del evento",
"OBSERVED IMAGERY": "IMÁGENES OBSERVADAS",
"SCHEMATIC CORRIDOR": "CORREDOR ESQUEMÁTICO",
"2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "REFERENCIA HISTÓRICA 2021 / POSTERIOR AL EVENTO 2026",
"2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "HISTÓRICA 2021 / TRAS EL EVENTO 2026",
"Historical reference and post-event image split": "División entre la imagen de referencia histórica y la posterior al evento",
"RECONSTRUCTION CLOCK": "RELOJ DE LA RECONSTRUCCIÓN",
"Schematic downstream progression": "Avance esquemático aguas abajo",
@@ -1123,12 +1123,12 @@
"{envVar} saved — paste to replace": "{envVar} guardada — pega otra para reemplazarla",
"paste {envVar}": "pega {envVar}",
"Project keys: {status}": "Claves del proyecto: {status}",
"Mapped camera locations": "Ubicaciones de cámaras cartografiadas",
"Mapped camera locations": "Ubicaciones de cámaras mapeadas",
"SHOW NEAREST": "MOSTRAR LA MÁS CERCANA",
"Stop following the current object before navigating to a camera": "Deja de seguir el objeto actual antes de ir a una cámara",
"Move to the nearest loaded camera and show its details": "Ir a la cámara cargada más cercana y mostrar sus detalles",
"Camera badges": "Iconos de cámaras",
"Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Las cámaras cian se vuelven coral al seleccionarlas. Las cuñas ilustran la dirección cartografiada, no la cobertura medida. Las cámaras cercanas pueden estar fuera de la pantalla.",
"Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Las cámaras cian se vuelven coral al seleccionarlas. Las cuñas ilustran la dirección mapeada, no la cobertura medida. Las cámaras cercanas pueden estar fuera de la pantalla.",
"{count} nearby · {onScreen} on screen": {
"one": "{count} cercana · {onScreen} en pantalla",
"many": "{count} de cámaras cercanas · {onScreen} en pantalla",
@@ -1139,16 +1139,16 @@
"many": "{count} de cámaras cercanas",
"other": "{count} cercanas"
},
"retrying mapped ALPR cameras": "reintentando cámaras ALPR cartografiadas",
"loading mapped ALPR cameras": "cargando cámaras ALPR cartografiadas",
"retrying mapped ALPR cameras": "reintentando cámaras ALPR mapeadas",
"loading mapped ALPR cameras": "cargando cámaras ALPR mapeadas",
"No ALPR data for this area — US and Canada only": "Sin datos ALPR para esta zona — solo EE. UU. y Canadá",
"Zoom in to load mapped cameras": "Haz zoom para cargar las cámaras cartografiadas",
"Zoom in to load mapped cameras": "Haz zoom para cargar las cámaras mapeadas",
"Showing cached locations": "Mostrando ubicaciones en caché",
"Coverage limited — zoom in": "Cobertura limitada — haz zoom",
"No ALPR data for this area": "Sin datos ALPR para esta zona",
"None on screen — nearby cameras are outside the view": "Ninguna en pantalla — las cámaras cercanas están fuera de la vista",
"ALPR camera": "Cámara ALPR",
"OpenStreetMap · community mapped": "OpenStreetMap · cartografiado por la comunidad",
"OpenStreetMap · community mapped": "OpenStreetMap · mapeado por la comunidad",
"Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlántico y Pacífico Norte oriental/central; no es una cobertura mundial de ciclones.",
"Potential tropical cyclone": "Posible ciclón tropical",
"Hurricane": "Huracán",
@@ -1231,17 +1231,17 @@
"Fully contained": "Totalmente contenido",
"Colour shows reported containment. Perimeters are simplified to about 100 m.": "El color indica la contención notificada. Los perímetros se simplifican a unos 100 m.",
"MORE DETAIL · 30 m": "MÁS DETALLE · 30 m",
"Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 y Landsat vía HLS, 30 m, cada 2–6 días",
"Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel‑2 y Landsat vía HLS, 30 m, cada 2–6 días",
"DAILY OVERVIEW · 250 m": "VISTA GENERAL DIARIA · 250 m",
"VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Mosaico diario de VIIRS, 250 m, sigue los cambios amplios; poco detalle por debajo de 25 km",
"Catalog truncated · a newer clear day may exist": "Catálogo truncado · puede existir un día despejado más reciente",
"{label} catalog unavailable · {detail}": "Catálogo de {label} no disponible · {detail}",
"Daily overview for today may still be empty until the pass": "La vista general diaria de hoy puede seguir vacía hasta el paso del satélite",
"Sentinel-2": "Sentinel-2",
"Sentinel-2": "Sentinel‑2",
"Landsat 8/9": "Landsat 8/9",
"Daily overview": "Vista general diaria",
"No frame within {gap} min of {time}": "Ningún fotograma a menos de {gap} min de {time}",
"No frame within {gap} h of {time}": "Ningún fotograma a menos de {gap} h de {time}",
"No frame within {gap} min of {time}": "Sin fotograma a ±{gap} min de {time}",
"No frame within {gap} h of {time}": "Sin fotograma a ±{gap} h de {time}",
"{hours}h {minutes}m ago": "hace {hours} h {minutes} min",
"{minutes}m ago": "hace {minutes} min",
"{state} · {time} · {lag}": "{state} · {time} · {lag}",
@@ -1451,10 +1451,10 @@
"Tilt map to oblique view": "Inclinar el mapa a una vista oblicua",
"Reset map to north up. Current heading {heading} degrees": "Orientar el mapa al norte. Rumbo actual: {heading} grados",
"HDG": "HDG",
"PITCH": "PITCH",
"PITCH": "INCL.",
"FOV": "FOV",
"RANGE": "RANGE",
"HGT": "HGT",
"RANGE": "DIST.",
"HGT": "ALT.",
"{value} KM/H": "{value} KM/H",
"NORMAL": "NORMAL",
"ANIME": "ANIME",
@@ -1515,7 +1515,7 @@
"Mode": "Modo",
"Imagery on Esri · Google 3D returns when cleared": "Imágenes sobre Esri · Google 3D vuelve al borrarlas",
"Imagery opacity": "Opacidad de las imágenes",
"SWAP": "CAMBIO",
"SWAP": "INVERTIR",
"VS BASEMAP": "VS MAPA BASE",
"Trade the two sides of the divider": "Intercambiar los dos lados del divisor",
"A / B": "A / B",
@@ -1568,7 +1568,7 @@
"START HERE · {reason}": "EMPIEZA AQUÍ · {reason}",
"Daily overview shows little detail in a box this small": "La vista general diaria muestra poco detalle en un recuadro tan pequeño",
"{percent}% cloud": "{percent}% de nubes",
"Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Imágenes: NASA GIBS y Worldview · HLS (Sentinel-2, Landsat 8/9) y VIIRS",
"Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Imágenes: NASA GIBS y Worldview · HLS (Sentinel‑2, Landsat 8/9) y VIIRS",
"{min}–{max}% cloud": "{min}–{max}% de nubes",
"Details": "Detalles",
"Times, coverage, sources": "Horas, cobertura, fuentes",
@@ -1593,7 +1593,7 @@
"Select a box or use the view": "Selecciona un recuadro o usa la vista",
"Searching the last 30 days": "Buscando en los últimos 30 días",
"No days to show for this box": "No hay días que mostrar para este recuadro",
"Drag the divider · SWAP trades sides": "Arrastra el divisor · CAMBIO invierte los lados",
"Drag the divider · SWAP trades sides": "Arrastra el divisor · INVERTIR cambia los lados",
"Export failed · {error}": "La exportación falló · {error}",
"← → preview · A or B pins the focused day": "← → vista previa · A o B fija el día enfocado",
"← → preview the other side · A or B pins it": "← → vista previa del otro lado · A o B lo fija",
@@ -1608,7 +1608,7 @@
"ESC EXIT": "ESC SALIR",
"C TOGGLE": "C ALTERNAR",
"SIG": "SIG",
"NEWS": "NEWS",
"NEWS": "NOTICIAS",
"LOCAL": "LOCAL",
"Cockpit briefing pages": "Páginas de información de la cabina",
"MM": "MM",
@@ -1616,11 +1616,11 @@
"FT": "FT",
"FLT": "FLT",
"MIL": "MIL",
"SITE": "SITE",
"SITE": "SITIOS",
"WX": "WX",
"DIRECTORY: RADIO BROWSER": "DIRECTORIO: RADIO BROWSER",
"Use your location for faster ADS-B position decoding": "Usa tu ubicación para decodificar más rápido las posiciones ADS-B",
"CHANGE DEVICE": "CAMBIAR DISPOSITIVO",
"CHANGE DEVICE": "CAMBIAR",
"Choose a different RTL-SDR or receiver channel": "Elige otro RTL-SDR u otro canal del receptor",
"TUNE": "SINTONIZAR",
"◀ SEEK": "◀ BUSCAR",
@@ -1644,11 +1644,11 @@
"POSITIONED": "POSICIONADAS",
"SDR VOL": "VOL. SDR",
"LOCAL RTL-SDR": "RTL-SDR LOCAL",
"Cyber sonar contact scanner": "Escáner sonar de contactos Cyber",
"Cyber sonar contact scanner": "Escáner sonar de contactos Ciber",
"Sonar ring count": "Número de anillos del sonar",
"Sonar ring range": "Alcance de los anillos del sonar",
"Sonar intensity": "Intensidad del sonar",
"Cyber contact and label opacity": "Opacidad de contactos y etiquetas Cyber",
"Cyber contact and label opacity": "Opacidad de contactos y etiquetas Ciber",
"Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Opacidad mínima de puntos, iconos, modelos, corchetes y etiquetas del mapa entre pasadas del sonar",
"Sonar sector width": "Ancho del sector del sonar",
"Rings": "Anillos",
@@ -1656,7 +1656,7 @@
"Power": "Potencia",
"Opacity": "Opacidad",
"Sector": "Sector",
"Cyber": "Cyber",
"Cyber": "Ciber",
"Sonar": "Sonar",
"Turn Street Level on or off": "Activar o desactivar A pie de calle",
"Toggle straight-down and tilted map views": "Alternar entre la vista cenital y la vista inclinada del mapa",
@@ -1671,7 +1671,7 @@
"{time} · {minutes} min ago": "{time} · hace {minutes} min",
"Active weather": "Clima activo",
"MINI": "MINI",
"STD": "STD",
"STD": "EST.",
"standard": "estándar",
"mini": "mini",
"COST UNKNOWN": "COSTO DESCONOCIDO",
@@ -1786,7 +1786,7 @@
"Cyclone advisories": "Avisos de ciclones",
"EDIT DETAILS": "EDITAR DETALLES",
"SHARE SCENE": "COMPARTIR ESCENA",
"ON/OFF": "ON/OFF",
"ON/OFF": "ENC./APAG.",
"Voice assistant": "Asistente de voz",
"VOICE": "VOZ",
"Dismiss voice card": "Cerrar la tarjeta de voz",
@@ -1811,7 +1811,7 @@
"overview": "vista general",
"{percent}% scene cloud": "{percent}% de nubes en la escena",
"{min}–{max}% scene cloud": "{min}–{max}% de nubes en la escena",
"Sentinel-2 via HLS": "Sentinel-2 vía HLS",
"Sentinel-2 via HLS": "Sentinel‑2 vía HLS",
"Landsat 8/9 via HLS": "Landsat 8/9 vía HLS",
"coverage|full": "completa",
"coverage|partial": "parcial",
@@ -1887,13 +1887,13 @@
"Use the {direction} lane": "Usa el carril {direction}",
"Use the {direction} lane onto {road}": "Usa el carril {direction} hacia {road}",
"voice|OFF": "APAG.",
"voice|CONNECTING": "CONEX…",
"voice|LISTENING": "OYENDO",
"voice|CONNECTING": "CONECT.",
"voice|LISTENING": "ESCUCHO",
"voice|EXECUTING": "ACCIÓN",
"voice|ERROR": "ERROR",
"MIC": "MIC",
"WORKING": "EN CURSO",
"DONE": "LISTO",
"DONE": "HECHO",
"Outline traced": "Contorno trazado",
"Outline unavailable": "Contorno no disponible",
"in progress": "en curso",
@@ -2020,7 +2020,7 @@
"Globe imagery unavailable": "Imágenes del globo no disponibles",
"Globe field image unavailable": "Imagen del campo en el globo no disponible",
"status chip|UNAVAILABLE": "NO DISP.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Usa contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
"Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Incluye contenido registrado o medios vinculados. Esos archivos externos no se incluyen en un paquete de escena. Sus avisos originales siguen vigentes.",
"{state} {layers}": "{state}: {layers}",
"Selected Space Mission": "Misión espacial seleccionada",
"{duration}s + {hold}s": "{duration} s + {hold} s",
@@ -2028,7 +2028,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} — se añade 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.",
"{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | VENTANA {window} | SUN {sun}° | ONA {ona}° | {zone}",
@@ -2041,11 +2041,37 @@
"zoom in to a street to open {name}": "acércate a una calle para abrir {name}",
"Google Street View": "Google Street View",
"GOOGLE STREET VIEW": "GOOGLE STREET VIEW",
"Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google rechazó GOOGLE_MAPS_API_KEY para Street View — comprueba que la API Maps JavaScript esté habilitada para ella y que este sitio sea un referente permitido",
"Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google rechazó GOOGLE_MAPS_API_KEY para Street View — comprueba que la clave tenga habilitada la API Maps JavaScript y que este sitio figure entre sus referentes HTTP permitidos",
"Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View no respondió — comprueba la red y que la API Maps JavaScript esté habilitada para GOOGLE_MAPS_API_KEY",
"click a street to open Street View": "haz clic en una calle para abrir Street View",
" or ": " o ",
"OPEN {labels}": "ABRIR {labels}",
"Open {names} at the centre of the view": "Abrir {names} en el centro de la vista",
"Zoom in to a street to open {names} here": "Acércate a una calle para abrir {names} aquí"
"Zoom in to a street to open {names} here": "Acércate a una calle para abrir {names} aquí",
"radio tag|All": "Todas",
"Alternative": "Alternativa",
"Ambient": "Ambiental",
"Blues": "Blues",
"Classical": "Clásica",
"Country": "Country",
"Dance": "Dance",
"Electronic": "Electrónica",
"Folk": "Folk",
"Funk": "Funk",
"Hip-Hop": "Hip-Hop",
"House": "House",
"Indie": "Indie",
"Jazz": "Jazz",
"Latin": "Latina",
"Metal": "Metal",
"Oldies": "Clásicos de siempre",
"Pop": "Pop",
"Punk": "Punk",
"R&B": "R&B",
"Reggae": "Reggae",
"Rock": "Rock",
"Soul": "Soul",
"Techno": "Techno",
"Trance": "Trance",
"World": "Músicas del mundo"
}
+31 -5
View File
@@ -1247,13 +1247,13 @@
"Fully contained": "Полностью локализован",
"Colour shows reported containment. Perimeters are simplified to about 100 m.": "Цвет показывает заявленную степень локализации. Контуры упрощены примерно до 100 м.",
"MORE DETAIL · 30 m": "БОЛЬШЕ ДЕТАЛЕЙ · 30 м",
"Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 и Landsat через HLS, 30 м, каждые 2–6 дней",
"Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel‑2 и Landsat через HLS, 30 м, каждые 2–6 дней",
"DAILY OVERVIEW · 250 m": "ЕЖЕДНЕВНЫЙ ОБЗОР · 250 м",
"VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "Ежедневная мозаика VIIRS, 250 м, показывает крупные изменения; мало деталей в масштабе менее 25 км",
"Catalog truncated · a newer clear day may exist": "Каталог усечён · может быть более новый ясный день",
"{label} catalog unavailable · {detail}": "Каталог {label} недоступен · {detail}",
"Daily overview for today may still be empty until the pass": "Ежедневный обзор за сегодня может оставаться пустым до пролёта спутника",
"Sentinel-2": "Sentinel-2",
"Sentinel-2": "Sentinel‑2",
"Landsat 8/9": "Landsat 8/9",
"Daily overview": "Ежедневный обзор",
"No frame within {gap} min of {time}": "Нет кадра в пределах {gap} мин от {time}",
@@ -1592,7 +1592,7 @@
"START HERE · {reason}": "НАЧНИТЕ ЗДЕСЬ · {reason}",
"Daily overview shows little detail in a box this small": "В такой маленькой области ежедневный обзор почти не показывает деталей",
"{percent}% cloud": "облачность {percent}%",
"Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Снимки: NASA GIBS и Worldview · HLS (Sentinel-2, Landsat 8/9) и VIIRS",
"Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Снимки: NASA GIBS и Worldview · HLS (Sentinel‑2, Landsat 8/9) и VIIRS",
"{min}–{max}% cloud": "облачность {min}–{max}%",
"Details": "Подробности",
"Times, coverage, sources": "Время, покрытие, источники",
@@ -1842,7 +1842,7 @@
"overview": "обзор",
"{percent}% scene cloud": "облачность сцены {percent}%",
"{min}–{max}% scene cloud": "облачность сцены {min}–{max}%",
"Sentinel-2 via HLS": "Sentinel-2 через HLS",
"Sentinel-2 via HLS": "Sentinel‑2 через HLS",
"Landsat 8/9 via HLS": "Landsat 8/9 через HLS",
"coverage|full": "полное",
"coverage|partial": "частичное",
@@ -2079,5 +2079,31 @@
" or ": " или ",
"OPEN {labels}": "ОТКРЫТЬ {labels}",
"Open {names} at the centre of the view": "Открыть {names} в центре вида",
"Zoom in to a street to open {names} here": "Приблизьтесь к улице, чтобы открыть здесь {names}"
"Zoom in to a street to open {names} here": "Приблизьтесь к улице, чтобы открыть здесь {names}",
"radio tag|All": "Все",
"Alternative": "Альтернатива",
"Ambient": "Эмбиент",
"Blues": "Блюз",
"Classical": "Классика",
"Country": "Кантри",
"Dance": "Танцевальная",
"Electronic": "Электроника",
"Folk": "Фолк",
"Funk": "Фанк",
"Hip-Hop": "Хип-хоп",
"House": "Хаус",
"Indie": "Инди",
"Jazz": "Джаз",
"Latin": "Латиноамериканская",
"Metal": "Метал",
"Oldies": "Ретро",
"Pop": "Поп",
"Punk": "Панк",
"R&B": "R&B",
"Reggae": "Регги",
"Rock": "Рок",
"Soul": "Соул",
"Techno": "Техно",
"Trance": "Транс",
"World": "Музыка мира"
}
+126
View File
@@ -0,0 +1,126 @@
{
"es": [
"AIS: --",
"AIS: {name}",
"ANIME",
"ASIA",
"AUTO",
"Anime",
"BRG {value}°",
"BRG —",
"Blues",
"CAL {value}",
"CAL · {badge}",
"CCTV",
"CRS {value}°",
"CRT",
"Country",
"DEST ---°",
"DEST {bearing}",
"Dance",
"ENE",
"ERROR",
"ESE",
"Error: {message}",
"FLIR",
"FLT",
"FM MHZ",
"FOV",
"FOV {value}°",
"FT",
"Folk",
"Funk",
"GEO",
"GOOGLE STREET VIEW",
"Global",
"Global · 60°S–60°N",
"Google Street View",
"HDG",
"HDG {value}°",
"Hip-Hop",
"House",
"Indie",
"Ironbow",
"Jazz",
"LOCAL",
"Landsat 8/9",
"MIC",
"MIL",
"MINI",
"MM",
"MONITOR",
"MSG/S",
"Metal",
"NAV",
"NE",
"NNE",
"NOIR",
"NORMAL",
"NVG",
"Noir",
"Normal",
"POS: {time}Z",
"PRECIP",
"Plan",
"Pop",
"Punk",
"R&B",
"RADIO",
"REC",
"Radio",
"Reggae",
"Rock",
"SAR",
"SE",
"SECTOR {lat} {lon}",
"SIG",
"SRC {value}",
"SSE",
"Sector",
"Sonar",
"Soul",
"TEMP",
"Techno",
"Trance",
"VISUAL",
"VS",
"WHOT/BHOT",
"WX",
"audio {state}",
"dB",
"ft",
"hPa",
"km",
"km/h",
"min",
"mini",
"mph",
"nominal",
"panel",
"view band|GLOBAL",
"voice|ERROR",
"{value} KM",
"{value} KM/H",
"{value} KM/S",
"ΔE",
"ΔN",
"▲ {frp} MW"
],
"ru": [
"AIS: --",
"AIS: {name}",
"CCTV",
"CRT",
"FLIR",
"GEO",
"Ironbow",
"Landsat 8/9",
"NAV",
"NVG",
"R&B",
"REC",
"VS",
"WHOT/BHOT",
"mini"
]
}
+2 -2
View File
@@ -70,7 +70,7 @@ export function createCategories({
function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : [];
const categories = [
{ id: 'all', label: t('All') },
{ id: 'all', label: t('All', { context: 'radio tag' }) },
{ id: 'news', label: t('News') },
{ id: 'talk', label: t('Talk') },
{ id: 'weather', label: t('Weather / Emergency') },
@@ -89,7 +89,7 @@ export function createCategories({
for (const [genre, label] of MUSIC_GENRES) {
const id = `genre:${genre}`;
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
categories.push({ id, label });
categories.push({ id, label: t(label) });
}
}
categories.push({ id: 'other', label: t('Other') });
+29 -25
View File
@@ -62,32 +62,36 @@ export const DEFAULT_RADIO_FILTER = 'all';
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
// Genre labels are marked for i18n extraction; categories.js translates them.
// Kept local so this module stays free of the i18n import.
const msg = (text) => text;
export const MUSIC_GENRES = Object.freeze([
['alternative', 'Alternative'],
['ambient', 'Ambient'],
['blues', 'Blues'],
['classical', 'Classical'],
['country', 'Country'],
['dance', 'Dance'],
['electronic', 'Electronic'],
['folk', 'Folk'],
['funk', 'Funk'],
['hip hop', 'Hip-Hop'],
['house', 'House'],
['indie', 'Indie'],
['jazz', 'Jazz'],
['latin', 'Latin'],
['metal', 'Metal'],
['oldies', 'Oldies'],
['pop', 'Pop'],
['punk', 'Punk'],
['r&b', 'R&B'],
['reggae', 'Reggae'],
['rock', 'Rock'],
['soul', 'Soul'],
['techno', 'Techno'],
['trance', 'Trance'],
['world', 'World'],
['alternative', msg('Alternative')],
['ambient', msg('Ambient')],
['blues', msg('Blues')],
['classical', msg('Classical')],
['country', msg('Country')],
['dance', msg('Dance')],
['electronic', msg('Electronic')],
['folk', msg('Folk')],
['funk', msg('Funk')],
['hip hop', msg('Hip-Hop')],
['house', msg('House')],
['indie', msg('Indie')],
['jazz', msg('Jazz')],
['latin', msg('Latin')],
['metal', msg('Metal')],
['oldies', msg('Oldies')],
['pop', msg('Pop')],
['punk', msg('Punk')],
['r&b', msg('R&B')],
['reggae', msg('Reggae')],
['rock', msg('Rock')],
['soul', msg('Soul')],
['techno', msg('Techno')],
['trance', msg('Trance')],
['world', msg('World')],
]);
export const CATEGORY_MATCHERS = Object.freeze({
+14 -3
View File
@@ -1,4 +1,12 @@
import { t } from '../i18n/index.js';
import { formatNumber, t } from '../i18n/index.js';
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) =>
formatNumber(value, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
useGrouping: false,
});
/**
* Own generated parameter rows and their input listeners.
@@ -44,11 +52,14 @@ export function createStyleParameters({
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
valueDisplay.textContent = parseFloat(slider.value).toFixed(digits);
valueDisplay.textContent = formatParam(
parseFloat(slider.value),
digits,
);
const onInput = () => {
const value = parseFloat(slider.value);
writeValue(name, value);
valueDisplay.textContent = value.toFixed(digits);
valueDisplay.textContent = formatParam(value, digits);
onChange();
};
slider.addEventListener('input', onInput);
+6
View File
@@ -399,6 +399,12 @@
color: rgba(222, 240, 245, 0.88);
}
/* A longer translated heading wraps; the readout beside it stays one piece. */
html:not([lang='en']) .bhote-event-section-head span:last-child {
flex: none;
white-space: nowrap;
}
.bhote-event-range {
width: 100%;
height: 4px;
+10
View File
@@ -404,3 +404,13 @@ html:not([lang='en']) #cctv-camera-select {
font-size: 10px;
letter-spacing: 0.04em;
}
/* The camera name gets its own row; PREV / NEXT share the row below it. */
html:not([lang='en']) .cctv-controls:has(#cctv-camera-select) {
flex-wrap: wrap;
}
html:not([lang='en']) #cctv-camera-select {
flex: 1 1 100%;
order: -1;
}
+12
View File
@@ -221,6 +221,18 @@
white-space: nowrap;
}
/* A longer translated title in a collapsed rail launcher tightens its
tracking rather than pushing the disclosure button out of the card. */
html:not([lang='en'])
#right-context-rail
.panel-collapsible.collapsed
.panel-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 2px;
}
.panel-divider {
flex: 1;
height: 1px;
+7
View File
@@ -647,6 +647,13 @@
overflow-wrap: anywhere;
}
/* Translated layer names are longer: break between words, and hyphenate a
word that still does not fit instead of splitting it anywhere. */
html:not([lang='en']) .data-name {
overflow-wrap: break-word;
hyphens: auto;
}
.data-toggle-right {
display: flex;
flex: 0 1 auto;
+9
View File
@@ -366,6 +366,15 @@
color: var(--text-secondary);
font-style: italic;
}
/* A translated slot line (date · source · resolution · state) is longer:
let it take a second line rather than cut off the state at the end. */
html:not([lang='en']) .recent-imagery-readout .ri-slot {
height: auto;
min-height: 20px;
}
html:not([lang='en']) .recent-imagery-readout .ri-slot-value {
white-space: normal;
}
.recent-imagery-readout .ri-unpin {
width: 22px;
padding: 0;
+6
View File
@@ -471,3 +471,9 @@ html:not([lang='en']) #scene-select {
font-size: 10px;
letter-spacing: 0.04em;
}
/* A translated scene name needs the width: the select takes its own row and
NEW / DELETE share the next one. */
html:not([lang='en']) #scene-select {
flex: 1 1 100%;
}