diff --git a/docs/I18N.md b/docs/I18N.md index 05c6a5a09..8e9e46ee9 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -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/.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/.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` | diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 688611401..96a00b016 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -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. diff --git a/scripts/i18n.mjs b/scripts/i18n.mjs index 622f13df6..b8a77a775 100644 --- a/scripts/i18n.mjs +++ b/scripts/i18n.mjs @@ -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} [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)); diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs index c52ef620c..4df60577e 100644 --- a/src/i18n/catalog.test.mjs +++ b/src/i18n/catalog.test.mjs @@ -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('Close'), ['Close']); diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index a0630e35b..9501423bc 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 19e994e9b..d9bb99622 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Музыка мира" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json new file mode 100644 index 000000000..5d0c10cca --- /dev/null +++ b/src/i18n/same-as-english.json @@ -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" + ] +} diff --git a/src/layers/radio/categories.js b/src/layers/radio/categories.js index c4a37224e..81407a7d3 100644 --- a/src/layers/radio/categories.js +++ b/src/layers/radio/categories.js @@ -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') }); diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js index 16ad23893..89e0c453a 100644 --- a/src/layers/radio/policy.js +++ b/src/layers/radio/policy.js @@ -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({ diff --git a/src/ui/styleParameters.js b/src/ui/styleParameters.js index 9b453a69a..c88872d69 100644 --- a/src/ui/styleParameters.js +++ b/src/ui/styleParameters.js @@ -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); diff --git a/src/ui/styles/bhote-koshi.css b/src/ui/styles/bhote-koshi.css index 326cbe282..55e8520bf 100644 --- a/src/ui/styles/bhote-koshi.css +++ b/src/ui/styles/bhote-koshi.css @@ -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; diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 6c8acbdcf..790885472 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -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; +} diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 464578073..5fdef1cf0 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -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; diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 85ae6cace..ba3f8e619 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -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; diff --git a/src/ui/styles/recent-imagery.css b/src/ui/styles/recent-imagery.css index 56cef6c17..11b8bfa1f 100644 --- a/src/ui/styles/recent-imagery.css +++ b/src/ui/styles/recent-imagery.css @@ -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; diff --git a/src/ui/styles/scenes.css b/src/ui/styles/scenes.css index fbec5ab04..8047f66e9 100644 --- a/src/ui/styles/scenes.css +++ b/src/ui/styles/scenes.css @@ -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%; +}