fix(i18n): second screenshot review: remaining English, numbers and wraps

From the re-shot Russian screenshots:
- Translated: weather frame/imagery status errors, HUD summary provenance
  ("НЕДОСТУПНО: СУДА AIS ОНЛАЙН"), Bhote Koshi map labels, cockpit signal
  distances (КМ). The HUD summary fields are separated with " · " in every
  language, so the style name and view band no longer read as one phrase.
- Numbers follow the locale: cockpit altitude ("26 175"), precipitation.
- No breaks inside codes and dates: ADS-B and similar in layer rows,
  Sentinel-2, cyclone issue dates (word joiner; the UI font has no U+2011),
  no-break space after label colons in cyclone details.
- Layout: count lines under ALPR, short unavailable chip (context
  "status chip"), roomier draw segments, tighter CCTV calibration chips,
  two-line beat titles and media card titles, wrapping weather status.
- Wording: ru advisory is «сводка» (was «бюллетень»; "Advisory center" is
  «Центр циклона по сводке»), Street View is «Просмотр улиц», OPACITY
  «НЕПРОЗР.», synced «синхр.», Area «Зона»; the import dialog no longer says
  "this scene" for several scenes. Glossary updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-13 19:13:12 +04:00
1 parent 2b6ccad732
commit 54dce5b3bd
32 files changed
+1204 -346

No files matched your search

+6 -2
View File
@@ -1,5 +1,5 @@
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
import { t, msg } from '../i18n/index.js';
import { formatNumber, t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
@@ -296,7 +296,11 @@ export function renderRegionalBrief(payload, info) {
this.localPrecipitation.textContent = Number.isFinite(
weather?.precipitationMm,
)
? weather.precipitationMm.toFixed(1)
? formatNumber(weather.precipitationMm, {
useGrouping: false,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: '—';
}
if (this.signalStream)
+2 -6
View File
@@ -8,6 +8,7 @@ import {
relativeBearing,
resolveCockpitContextReadout,
} from '../cockpitMath.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
/** The Context scope line in the active language (see cockpitMath.js). */
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
@@ -117,12 +118,7 @@ export function updateContext(info, heading) {
if (this.contextDistance) {
const distanceM = closest?.distanceM;
this.contextDistance.textContent = Number.isFinite(distanceM)
? t('{value} KM', {
value:
distanceM < 10000
? (distanceM / 1000).toFixed(1)
: Math.round(distanceM / 1000),
})
? formatCockpitDistance(distanceM)
: '—';
this.contextDistance.setAttribute(
'aria-label',
+2 -2
View File
@@ -1,5 +1,5 @@
/** Instrument values, routes and vision controls for the Cockpit controller. */
import { t } from '../i18n/index.js';
import { formatNumber, t } from '../i18n/index.js';
import {
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
@@ -69,7 +69,7 @@ export function updateHud(
setCockpitRollingValue(
this.altitude,
displayedAltitudeFt !== null
? displayedAltitudeFt.toLocaleString('en-US')
? formatNumber(displayedAltitudeFt)
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
+25 -2
View File
@@ -1,5 +1,6 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -126,6 +127,18 @@ export function formatCockpitCoordinate(value, positive, negative) {
return `${amount}°${compassPointLabel(value >= 0 ? positive : negative)}`;
}
/**
* A contact distance such as `4.2 KM` / `44 KM` in the active language
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
*/
export function formatCockpitDistance(distanceM) {
const digits = distanceM < 10000 ? 1 : 0;
return formatQuantity(distanceM / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).toUpperCase();
}
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
@@ -133,6 +146,16 @@ export function formatCockpitWindDirection(value) {
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
}
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
// narrow cell; a full digit cell would open a gap in the middle of a number.
const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/;
function rollTokenClass(character) {
return ROLL_SEPARATOR.test(character)
? 'cockpit-roll-token is-separator'
: 'cockpit-roll-token';
}
export function setCockpitRollingValue(
element,
text,
@@ -169,7 +192,7 @@ export function setCockpitRollingValue(
element.replaceChildren(
...Array.from(nextText, (character) => {
const token = document.createElement('span');
token.className = 'cockpit-roll-token';
token.className = rollTokenClass(character);
token.setAttribute('aria-hidden', 'true');
token.textContent = character;
return token;
@@ -198,7 +221,7 @@ export function setCockpitRollingValue(
const previousCharacter = from[index];
const nextCharacter = to[index];
const token = document.createElement('span');
token.className = 'cockpit-roll-token';
token.className = rollTokenClass(nextCharacter);
token.setAttribute('aria-hidden', 'true');
if (
+2 -1
View File
@@ -1,5 +1,6 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
import { t, tn } from '../i18n/index.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
export function renderCockpitSignals() {
if (!this.signalList) return;
@@ -157,7 +158,7 @@ export function updateCockpitSignals(snapshot, unknownCount) {
? t('MILITARY FLIGHT')
: t('COMMERCIAL FLIGHT'),
distance: Number.isFinite(item.distanceM)
? `${item.distanceM < 10000 ? (item.distanceM / 1000).toFixed(1) : Math.round(item.distanceM / 1000)} KM`
? formatCockpitDistance(item.distanceM)
: t('DISTANCE UNKNOWN'),
}),
target: { layerId: cohort.id, id: String(id) },
+89 -8
View File
@@ -206,6 +206,64 @@ export function layerKeyRequirementTooltip(layer = {}) {
: '';
}
const ROW_TEXT_SEPARATOR = ' · ';
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
// error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
const HYPHENATED_CODE_MAX = 16;
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
function rowTextSpan(text, keep) {
const span = document.createElement('span');
if (keep) span.className = 'data-keep';
span.textContent = text;
return span;
}
/**
* Set a row's name, count or meta line so that a narrow column wraps it well
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
* splits at its hyphen, and a " · " separator stays at the end of the line it
* closes, so no line starts with "·". The element's textContent is still the
* plain string; text with neither feature is set directly.
*
* @param {HTMLElement} element
* @param {string} text
*/
export function setRowText(element, text) {
const value = String(text ?? '');
// Refreshes run every second; leave unchanged text (and its spans) alone.
if (element.textContent === value) return;
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
(match) => match[0].length <= HYPHENATED_CODE_MAX,
);
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) {
element.textContent = value;
return;
}
const spans = [];
const pushPlain = (from, to) => {
if (to <= from) return;
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
pieces.forEach((piece, index) => {
// The space before the dot belongs to a no-wrap span, so the only break
// opportunity around a separator is the space after it.
if (index > 0) spans.push(rowTextSpan(' ·', true));
const textPiece = index > 0 ? ` ${piece}` : piece;
if (textPiece) spans.push(rowTextSpan(textPiece, false));
});
};
let cursor = 0;
for (const match of codes) {
pushPlain(cursor, match.index);
spans.push(rowTextSpan(match[0], true));
cursor = match.index + match[0].length;
}
pushPlain(cursor, value.length);
element.textContent = '';
for (const span of spans) element.appendChild(span);
}
/** Layer row presentation over supplied state and actions; no layer imports. */
export class LayerPanel {
constructor({
@@ -331,7 +389,7 @@ export class LayerPanel {
icon.textContent = layer.icon;
const name = document.createElement('span');
name.className = 'data-name';
name.textContent = panelLabel(layer);
setRowText(name, panelLabel(layer));
left.appendChild(icon);
left.appendChild(name);
@@ -340,7 +398,7 @@ export class LayerPanel {
const count = document.createElement('span');
count.className = 'data-count';
count.textContent = this._layerCountText(layer.stats);
this._syncCount(row, count, layer.stats);
const toggle = document.createElement('button');
toggle.type = 'button';
@@ -379,7 +437,7 @@ export class LayerPanel {
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
bottomRow.textContent = this._buildMetaText(layer);
setRowText(bottomRow, this._buildMetaText(layer));
row.appendChild(topRow);
row.appendChild(bottomRow);
@@ -482,6 +540,21 @@ export class LayerPanel {
);
}
/**
* Write a row's count. A count that is a phrase carrying its own number
* ("24 nearby · 2 on screen") moves to its own line under the name: beside
* the toggle it squeezes the name to a word per line in any language. The
* "—" placeholder is marked so it can give way to a wide status chip.
*/
_syncCount(row, count, stats) {
const text = this._layerCountText(stats);
setRowText(count, text);
const phrase =
typeof stats.countLabel === 'string' && /\d/.test(stats.countLabel);
row.classList.toggle('data-toggle-row--count-line', phrase);
count.classList.toggle('data-count-empty', text === '—');
}
/** Qualify a loaded count when it does not mean items currently on screen. */
_layerCountText(stats) {
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
@@ -605,12 +678,12 @@ export class LayerPanel {
const count = row.querySelector('.data-count');
if (count) {
count.textContent = this._layerCountText(layer.stats);
this._syncCount(row, count, layer.stats);
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
meta.textContent = this._buildMetaText(layer);
setRowText(meta, this._buildMetaText(layer));
}
this._syncRowControls(
@@ -765,13 +838,21 @@ export class LayerPanel {
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
button.textContent = transitioning
const showsFeedState =
!transitioning && !uncertain && (layer.enabled || unavailable);
const stateText = transitioning
? lifecycleStateLabel(layer.lifecycleState)
: uncertain
? t('UNCERTAIN')
: layer.enabled || unavailable
: showsFeedState
? t(FEED_STATE_LABEL_KEYS[feedState])
: t('OFF');
// The chip may abbreviate where a language's word is wider than the
// status column; the meta line and the accessible name keep the full word.
button.textContent =
showsFeedState && feedState === 'unavailable'
? t('UNAVAILABLE', { context: 'status chip' })
: stateText;
const keyGuidance = unavailable
? String(layer.stats.error || t('Data source unavailable'))
: layerKeyRequirementTooltip(layer);
@@ -781,7 +862,7 @@ export class LayerPanel {
button.title = keyGuidance;
const ariaLabel = t('{name}: {state}', {
name: panelLabel(layer),
state: button.textContent,
state: stateText,
});
button.setAttribute(
'aria-label',
+69
View File
@@ -260,3 +260,72 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
'an active layer can always be switched off',
);
});
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
const { setRowText } = await import('./layerPanel.js');
const previousDocument = globalThis.document;
const element = () => ({
className: '',
textContent: '',
children: [],
appendChild(child) {
this.children.push(child);
return child;
},
});
globalThis.document = { createElement: element };
try {
const text = (node) =>
node.children.length
? node.children.map((child) => child.textContent).join('')
: node.textContent;
const kept = (node) =>
node.children
.filter((child) => child.className === 'data-keep')
.map((child) => child.textContent);
const name = element();
setRowText(name, 'Локальный ADS-B');
assert.equal(text(name), 'Локальный ADS-B');
assert.deepEqual(kept(name), ['ADS-B']);
const meta = element();
setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
assert.ok(
meta.children.every((child) => !child.textContent.startsWith('·')),
'the space before each dot is held to the piece it closes',
);
const plain = element();
setRowText(plain, 'Satellites');
assert.equal(plain.textContent, 'Satellites');
assert.equal(plain.children.length, 0);
const long = element();
setRowText(long, 'see https-proxy-endpoint-config-x');
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
} finally {
globalThis.document = previousDocument;
}
});
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
LayerPanel.prototype._syncToggleButton(button, {
id: 'local-firms',
name: 'FIRMS Active Fires',
enabled: false,
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
});
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(button.dataset.feedState, 'unavailable');
assert.match(attrs.get('aria-label'), /^FIRMS Active Fires: UNAVAILABLE\./);
});
+14 -8
View File
@@ -281,8 +281,10 @@
border-top: 3px solid #6be8ff;
}
/* Up to two lines, so a translated title is not cut to a stub. */
/* The title takes the full card width and up to two lines, so a translated
title keeps its place name; the link sits beside the status below it. */
.bhote-embedded-callout footer strong {
grid-column: 1 / -1;
display: -webkit-box;
overflow: hidden;
font-size: 11px;
@@ -292,9 +294,9 @@
-webkit-line-clamp: 2;
}
/* The status takes the full width under the title and link. */
.bhote-embedded-callout footer small {
grid-column: 1 / -1;
grid-column: 1;
align-self: center;
color: rgba(151, 211, 224, 0.7);
font-size: 8px;
line-height: 1.35;
@@ -302,7 +304,7 @@
.bhote-embedded-callout footer a {
grid-column: 2;
grid-row: 1;
grid-row: 2;
align-self: center;
white-space: nowrap;
color: #6be8ff;
@@ -450,7 +452,7 @@
align-content: center;
column-gap: 9px;
min-width: 0;
padding: 7px 10px;
padding: 5px 10px;
border: 1px solid rgba(88, 196, 218, 0.18);
background: rgba(6, 19, 27, 0.72);
}
@@ -462,20 +464,24 @@
line-height: 1.25;
}
/* Up to two lines: a translated beat title keeps its subject. Two title lines
and one meta line still fit the 54px arrow buttons, so the controls stay put. */
.bhote-event-story-current strong {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
font-size: 11px;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
.bhote-event-story-current small {
display: -webkit-box;
grid-column: 1 / -1;
margin-top: 4px;
margin-top: 3px;
overflow: hidden;
color: rgba(116, 216, 235, 0.7);
-webkit-box-orient: vertical;
+8 -4
View File
@@ -106,22 +106,26 @@ html[lang='es'] .cctv-controls {
}
/* Click-to-edit effective-pose readout (viewshed/gizmo design §3d): compact
value chips replace the seven offset sliders. */
value chips replace the seven offset sliders. Tight enough that the seven
chips take two rows in a long-label language too, which keeps SAVE / RESET
in view; chips grow to fill each row, and a value never wraps inside one. */
.cctv-cal-readout {
display: flex;
flex-wrap: wrap;
gap: 5px;
gap: 4px;
}
.cctv-cal-value {
flex: 1 1 auto;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.7px;
letter-spacing: 0.3px;
color: #91edff;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 7px;
padding: 4px 7px;
padding: 4px;
cursor: text;
transition:
border-color 0.15s ease,
+3
View File
@@ -569,6 +569,9 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed))
text-align: center;
vertical-align: -0.08em;
}
.cockpit-roll-token.is-separator {
min-width: 0.3em;
}
.cockpit-roll-token.is-rolling {
color: color-mix(in srgb, currentColor 92%, var(--cockpit-accent));
text-shadow: 0 0 10px
+5
View File
@@ -977,6 +977,11 @@
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long shape name never runs
edge to edge in its segment. */
#draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
+51 -1
View File
@@ -663,6 +663,54 @@
text-wrap: balance;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
.data-keep {
white-space: nowrap;
}
/* A count phrase ("24 nearby · 2 on screen") takes its own line under the
name instead of squeezing it beside the toggle. */
.data-toggle-row--count-line .data-toggle-top {
flex-wrap: wrap;
row-gap: 2px;
}
.data-toggle-row--count-line .data-toggle-right {
display: contents;
}
/* A zero basis, so a long name wraps beside the toggle instead of pushing the
toggle onto the count's line. */
.data-toggle-row--count-line .data-toggle-left {
flex-basis: 0;
}
.data-toggle-row--count-line .data-count {
order: 1;
flex: 1 0 100%;
padding-left: 26px;
font-size: 10px;
text-align: left;
}
/* The "—" placeholder gives way to a wide status chip, so the name keeps the
room it needs. */
.data-toggle-right:has(
.data-toggle-btn:is(
.transitioning,
.feed-loading,
.feed-degraded,
.feed-stale,
.feed-partial,
.feed-fallback,
.feed-unavailable
)
)
.data-count-empty {
display: none;
}
.data-toggle-btn {
padding: 3px 10px;
font-family: var(--font-mono);
@@ -693,13 +741,15 @@
outline-offset: -3px;
}
/* Status chips up to eight characters share one width; a longer word grows
its own chip rather than widening every chip and squeezing every name. */
.data-toggle-btn.feed-loading,
.data-toggle-btn.feed-degraded,
.data-toggle-btn.feed-stale,
.data-toggle-btn.feed-partial,
.data-toggle-btn.feed-fallback,
.data-toggle-btn.feed-unavailable {
min-width: 82px;
min-width: 72px;
letter-spacing: 0.8px;
}
+3 -2
View File
@@ -147,9 +147,10 @@
.rail-card-line {
white-space: pre-line;
}
/* One line stays reserved so the card does not jump when a status appears;
a long (or translated) status wraps instead of being cut. */
.weather-card [data-line-id='status'] {
height: 1.45em;
overflow-y: auto;
min-height: 1.45em;
color: var(--accent);
}
.rail-card-ramp {