Merge remote-tracking branch 'origin/main' into german/hal-644-ar

# Conflicts:
#	docs/CURRENT-STATE.md
#	docs/TRANSLATORS.md
#	scripts/package-boundaries.json
#	src/hudLocalityFormat.js
#	src/i18n/index.js
#	src/i18n/locale.js
#	src/i18n/same-as-english.json
#	src/layers/cctv/presentation.js
#	src/scenes/recipes.js
#	src/ui/localePicker.test.mjs
#	src/ui/unitLabels.js
This commit is contained in:
German Khokhlov committed 2026-10-29 20:05:03 +04:00
commit 188a6086b8
114 files changed
+20791 -407

No files matched your search

+1 -1
View File
@@ -1598,7 +1598,7 @@ export class StyleManager extends ShellFacade {
* 2. Crossfades the new shader stage intensity to 1.
* 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
* 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
* @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @param {boolean} [options.userInitiated=false] - Whether a direct user preset choice owns the Cyber exit style.
-1
View File
@@ -5,7 +5,6 @@ const STYLE_KEYS = Object.freeze({
4: 'thermal',
5: 'anime',
6: 'noir',
7: 'snow',
});
/**
+6 -3
View File
@@ -1,6 +1,6 @@
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
import * as Cesium from 'cesium';
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
import { formatNumber, t, tn } from '../i18n/index.js';
import { unitLabel } from './unitLabels.js';
import {
bearingBetweenCoordinates,
@@ -8,14 +8,17 @@ import {
relativeBearing,
resolveCockpitContextReadout,
} from '../cockpitMath.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
import {
cockpitUnitCase,
formatCockpitDistance,
} from './cockpitPresentation.js';
/** The Context scope line in the active language (see cockpitMath.js). */
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
window: ({ label, radiusKm }) =>
t('{label} · {radius} AIR/SEA WINDOW', {
label,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${cockpitUnitCase(unitLabel('km'))}`,
}),
installations: ({ scope, coverage }) =>
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
-2
View File
@@ -268,7 +268,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
thermal: t('FLIR'),
anime: t('ANIME'),
noir: t('NOIR'),
snow: t('SNOW'),
};
const names = {
optical: t('Normal'),
@@ -277,7 +276,6 @@ export function setVisionMode(mode, { revealParameters = false } = {}) {
thermal: t('Thermal'),
anime: t('Anime'),
noir: t('Noir'),
snow: t('Snow'),
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
+9 -1
View File
@@ -150,7 +150,15 @@ export function formatCockpitDistance(distanceM) {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
return cockpitUnitCase(distance);
}
/**
* Upper-case a cockpit unit the HUD way (`9.5 KM`), except in Chinese and
* Japanese, which have no case and keep units as the catalog writes them.
*/
export function cockpitUnitCase(text) {
return /^(zh|ja)(-|$)/.test(getLocale()) ? text : text.toUpperCase();
}
export function formatCockpitWindDirection(value) {
+26 -20
View File
@@ -105,7 +105,6 @@ test('visual styles retain Cyber through real preset application without droppin
'normal',
'noir',
'anime',
'snow',
'thermal',
'retro',
]) {
@@ -162,8 +161,8 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
assert.equal(owner.activeStyle, 'snow');
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, true);
@@ -172,16 +171,16 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
// A later untouched Cyber round trip restores the new pre-Cyber preset.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._preCyberStyle, 'snow');
assert.equal(owner._preCyberStyle, 'anime');
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
});
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
@@ -200,15 +199,15 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('cyber');
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'snow');
assert.equal(owner.activeStyle, 'anime');
});
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
@@ -226,10 +225,10 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const snowButton = new EventTarget();
snowButton.dataset = { style: 'snow' };
const animeButton = new EventTarget();
animeButton.dataset = { style: 'anime' };
const controls = bindDisplayControls({
elements: { hudLayout, styleButtons: [snowButton] },
elements: { hudLayout, styleButtons: [animeButton] },
actions: {
setHudLayout: (value) => display.owner.setHudLayout(value),
setStyle: (value) =>
@@ -239,12 +238,12 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
snowButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'snow');
animeButton.dispatchEvent(new Event('click'));
assert.equal(display.owner.activeStyle, 'anime');
hudLayout.value = 'operator';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
assert.equal(display.owner.activeStyle, 'snow');
assert.equal(display.owner.activeStyle, 'anime');
controls.destroy();
const voice = makeOwner();
@@ -263,13 +262,13 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
assert.equal(entered.ok, true);
assert.equal(voice.hud.getVariant(), 'cyber');
assert.equal(voice.owner.activeStyle, 'thermal');
const selected = await runVoiceAction('set_visual_style', { style: 'snow' });
const selected = await runVoiceAction('set_visual_style', { style: 'anime' });
assert.equal(selected.ok, true);
assert.equal(voice.owner.activeStyle, 'snow');
assert.equal(voice.owner.activeStyle, 'anime');
const exited = await runVoiceAction('set_hud', { layout: 'operator' });
assert.equal(exited.ok, true);
assert.equal(voice.hud.getVariant(), 'operator');
assert.equal(voice.owner.activeStyle, 'snow');
assert.equal(voice.owner.activeStyle, 'anime');
});
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
@@ -301,11 +300,18 @@ test('scope choice and expanded feather survive every HUD layout and both restor
assert.equal(owner._scopeFeatherSlider.value, '200');
});
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
const { owner } = styleOwner(t, 'tactical');
owner.setStyle('noir');
await owner.applyVisualState({ style: 'snow' });
assert.equal(owner.activeStyle, 'normal');
});
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
const { owner, hud } = styleOwner(t, 'tactical');
owner.setStyle('noir');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
assert.equal(owner._cyberStyleUserSelected, true);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
@@ -319,7 +325,7 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset',
owner.setStyle('surveillance');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'surveillance');
owner.setStyle('snow', { userInitiated: true });
owner.setStyle('anime', { userInitiated: true });
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
+5 -3
View File
@@ -136,9 +136,11 @@ 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;
// A short Latin code joined by hyphens: every part after a hyphen is capitals
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
// in an error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-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. */
+9
View File
@@ -324,6 +324,15 @@ test('row text keeps a hyphenated code whole and never starts a line with the se
assert.equal(plain.textContent, 'Satellites');
assert.equal(plain.children.length, 0);
const compound = element();
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.deepEqual(
kept(compound),
[' ·', 'ADS-B'],
'a compound may break at its hyphen; the code inside it stays whole',
);
const long = element();
setRowText(long, 'see https-proxy-endpoint-config-x');
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
+75 -8
View File
@@ -31,6 +31,9 @@ const MANY = Object.keys(NAMES);
const codes = (rows) => rows.map((row) => row.code);
// Few enough languages that the picker has no search field.
const UNSEARCHED = ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'];
test('the current language comes first, the rest by their own names', () => {
assert.deepEqual(
codes(
@@ -40,7 +43,20 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es', 'ar', 'zh-CN', 'zh-Hant'],
[
'ru',
'uk',
'de',
'en',
'es',
'fr',
'it',
'pt',
'ar',
'zh-CN',
'zh-Hant',
'ja',
],
);
assert.deepEqual(
codes(
@@ -50,7 +66,20 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru', 'ar', 'zh-CN', 'zh-Hant'],
[
'es',
'de',
'en',
'fr',
'it',
'pt',
'ru',
'uk',
'ar',
'zh-CN',
'zh-Hant',
'ja',
],
);
assert.deepEqual(
codes(
@@ -60,7 +89,20 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru', 'ar', 'zh-CN', 'zh-Hant'],
[
'en',
'de',
'es',
'fr',
'it',
'pt',
'ru',
'uk',
'ar',
'zh-CN',
'zh-Hant',
'ja',
],
);
assert.deepEqual(
codes(
@@ -70,7 +112,20 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ar', 'en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
[
'ar',
'de',
'en',
'es',
'fr',
'it',
'pt',
'ru',
'uk',
'zh-CN',
'zh-Hant',
'ja',
],
);
const rows = buildLocaleOptions({
locales: MANY,
@@ -105,6 +160,8 @@ test('each row gets the interface-language name for search, unless it only repea
const byCode = Object.fromEntries(rows.map((row) => [row.code, row]));
assert.equal(byCode.en.localName, 'английский');
assert.equal(byCode.es.localName, 'испанский');
assert.equal(byCode.de.localName, 'немецкий');
assert.equal(byCode.fr.localName, 'французский');
assert.equal(byCode.ru.localName, '', 'русский only repeats Русский');
const english = buildLocaleOptions({
locales: LOCALES,
@@ -113,6 +170,7 @@ test('each row gets the interface-language name for search, unless it only repea
localName: createLocalNameReader('en'),
});
assert.equal(english.find((row) => row.code === 'es').localName, 'Spanish');
assert.equal(english.find((row) => row.code === 'fr').localName, 'French');
});
test('without Intl.DisplayNames the interface-language name is simply left out', () => {
@@ -175,7 +233,10 @@ test('search folds case and diacritics, and matches either name or the code', ()
test('the search field appears only above the threshold', () => {
assert.equal(LOCALE_SEARCH_THRESHOLD, 8);
assert.equal(createLocalePickerModel({ current: 'en' }).searchable, false);
assert.equal(
createLocalePickerModel({ current: 'en' }).searchable,
LOCALES.length > LOCALE_SEARCH_THRESHOLD,
);
const eight = MANY.slice(0, 8);
assert.equal(
createLocalePickerModel({
@@ -260,6 +321,7 @@ test('type-ahead cycles by first letter and refines with more letters', () => {
test('keyboard model: open on the current language, move, choose, close', () => {
const model = createLocalePickerModel({
locales: UNSEARCHED,
current: 'ru',
localName: createLocalNameReader('ru'),
});
@@ -271,7 +333,8 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.openList();
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
// Russian collation puts the other Cyrillic name next.
assert.equal(model.activeOption.code, 'uk');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'zh-Hant');
model.keydown({ key: 'Home' });
@@ -285,7 +348,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.keydown({ key: 'ArrowDown' });
assert.deepEqual(model.keydown({ key: ' ' }), {
handled: true,
choose: 'en',
choose: 'uk',
});
assert.deepEqual(model.keydown({ key: 'Escape' }), {
handled: true,
@@ -302,7 +365,11 @@ test('keyboard model: open on the current language, move, choose, close', () =>
test('keyboard model: type-ahead resets after a pause and ignores shortcuts', () => {
let clock = 0;
const model = createLocalePickerModel({ current: 'en', now: () => clock });
const model = createLocalePickerModel({
locales: UNSEARCHED,
current: 'en',
now: () => clock,
});
model.openList();
assert.deepEqual(model.keydown({ key: 'e' }), { handled: true });
assert.equal(model.activeOption.code, 'es');
-1
View File
@@ -12,7 +12,6 @@ const STYLE_LABELS = Object.freeze({
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
const DETECTION_MODE_LABELS = Object.freeze({
+3 -2
View File
@@ -518,14 +518,15 @@ html:not([lang='en']) .bhote-event-story-current strong {
-webkit-line-clamp: 2;
}
/* Phase / time / sources run long in translation: allow a second line. */
/* Phase / time / sources / imagery note run long in translation: allow up to
three lines so the last status is not cut off. */
html:not([lang='en']) .bhote-event-story-current small {
display: -webkit-box;
margin-top: 3px;
text-overflow: clip;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
.bhote-event-actions {
+44 -16
View File
@@ -1,6 +1,7 @@
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
intel HUD readouts and content marked translate="no", including its containers. */
html:lang(zh)
/* Chinese and Japanese text. Tight tracking keeps CJK labels readable;
preserve Latin tracking for intel HUD readouts and content marked
translate="no", including its containers. */
html:is(:lang(zh), :lang(ja))
body
:not(
#intel-hud,
@@ -12,40 +13,67 @@ html:lang(zh)
letter-spacing: 0.02em !important;
}
/* Keep small Chinese labels legible at a minimum of 9px. */
html:lang(zh) #command-dock .gev-mic-label,
html:lang(zh) #command-dock #gev-voice-detail,
html:lang(zh) .gev-voice-tier-btn,
html:lang(zh) .panel-layer-toggle {
/* Japanese line breaking: never start a line with ー, a small kana or closing
punctuation (プレビュ / ー). */
html:lang(ja) body {
line-break: strict;
}
/* Keep small CJK labels legible at a minimum of 9px. */
html:is(:lang(zh), :lang(ja)) #command-dock .gev-mic-label,
html:is(:lang(zh), :lang(ja)) #command-dock #gev-voice-detail,
html:is(:lang(zh), :lang(ja)) .gev-voice-tier-btn,
html:is(:lang(zh), :lang(ja)) .panel-layer-toggle {
font-size: 9px;
}
/* A two-glyph button label must not break between its glyphs. */
html:lang(zh) .pp-mode-btn {
html:is(:lang(zh), :lang(ja)) .pp-mode-btn {
white-space: nowrap;
}
/* Japanese segment labels are short words (すべて, 平面): keep each whole. */
html:lang(ja) .gev-segment {
white-space: nowrap;
}
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
html:lang(zh) #intel-hud *,
html:lang(zh) #cockpit-hud * {
html:is(:lang(zh), :lang(ja)) #intel-hud *,
html:is(:lang(zh), :lang(ja)) #cockpit-hud * {
text-transform: none;
}
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
/* Move short travel modes (步行 / 骑行, 徒歩 / 自転車) to the next line as
whole words. */
html:is(:lang(zh), :lang(ja)) [data-layer-id='directions'] .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Leave room for the complete sensor name and resolution on two lines. */
html:lang(zh) .recent-imagery-readout .ri-strip {
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-strip {
height: 131px;
}
html:lang(zh) .recent-imagery-readout .ri-card {
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card {
height: 123px;
grid-template-rows: 42px 13px 22px 11px 18px;
}
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
html:is(:lang(zh), :lang(ja)) .recent-imagery-readout .ri-card-sensor {
white-space: normal;
text-overflow: clip;
}
/* The Japanese imagery slot line wraps at its · separators, never inside a
word (プレ / ビュー); a run too long for the line still breaks. */
html:lang(ja) .recent-imagery-readout .ri-slot-value {
word-break: keep-all;
overflow-wrap: break-word;
}
/* Japanese layer status lines wrap at their spaces and · separators, so an
age such as 1分以内 stays whole (更新 1 / 分以内); a run too long for the
line still breaks. */
html:lang(ja) .data-toggle-meta {
word-break: keep-all;
overflow-wrap: break-word;
}
+2 -1
View File
@@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
}
.cockpit-vision-controls {
display: grid;
grid-template-columns: 48px 58px 48px;
/* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */
grid-template-columns: 48px minmax(58px, max-content) 48px;
align-items: stretch;
gap: 2px;
margin-top: 2px;
-6
View File
@@ -747,12 +747,6 @@
.btn-icon::before {
content: '◐';
}
:root[data-ui-theme='cyber']
#command-dock
.style-btn[data-style='snow']
.btn-icon::before {
content: '❄︎';
}
:root[data-ui-theme='cyber'] :where(#bloom-toggle, #sharpen-toggle) {
font-size: 0;
+6
View File
@@ -688,6 +688,12 @@ html:not([lang='en']) .data-count {
text-wrap: balance;
}
/* The status column keeps its width, so a wide chip (WIRD AKTIVIERT) never
overlaps the name; the name wraps in the room left beside it. */
html:not([lang='en']) .data-toggle-right {
flex: none;
}
/* Row text pieces that must not break inside: a hyphenated code (ADS-B) and
the space before a " · " separator (see setRowText in layerPanel.js). */
html:not([lang='en']) .data-keep {
-5
View File
@@ -43,11 +43,6 @@
<span class="btn-label" data-i18n>Noir</span>
<span class="btn-key">6</span>
</button>
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene." data-i18n-title>
<span class="btn-icon">❄</span>
<span class="btn-label" data-i18n>Snow</span>
<span class="btn-key">7</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
+1 -1
View File
@@ -122,7 +122,7 @@
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false" data-i18n>ENABLE</button>
<label class="radio-filter-label" for="radio-filter" data-i18n>STATION TAG</label>
<select class="gev-field" id="radio-filter" aria-label="Filter stations by station tag" disabled data-i18n-aria-label>
<option value="all" data-i18n>All</option>
<option value="all" data-i18n data-i18n-context="radio tag">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
+15 -2
View File
@@ -1,4 +1,4 @@
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
import { formatNumber, getLocale, isolate, msg, t } from '../i18n/index.js';
// Unit ids stay English in data, params and AI-tool readings; only the label
// shown on the page is translated. dBZ and °C read the same in every language.
@@ -23,6 +23,17 @@ export function unitLabel(unit) {
return Object.hasOwn(UNIT_LABELS, unit) ? t(UNIT_LABELS[unit]) : unit || '';
}
/**
* What goes between a number and its unit label: a no-break space, except
* before a Japanese unit word, which follows the number directly (5分, 1時間).
*/
export function quantitySeparator(label) {
return getLocale() === 'ja' &&
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u.test(label)
? ''
: '\u00a0';
}
/**
* A number and its unit that never break apart: "22,0 км/ч". Readings carry
* no digit grouping ("1008 hPa"), as on weather charts.
@@ -30,7 +41,9 @@ export function unitLabel(unit) {
export function formatQuantity(value, unit, options) {
const label = unitLabel(unit);
const number = formatNumber(value, { useGrouping: false, ...options });
return label ? isolate(`${number}\u00a0${label}`) : number;
return label
? isolate(`${number}${quantitySeparator(label)}${label}`)
: number;
}
/**
+11
View File
@@ -44,3 +44,14 @@ test('other languages get their decimal separator and no digit grouping', () =>
setLocale('en');
}
});
test('Japanese unit words follow the number directly; symbols keep the space', () => {
try {
setLocale('ja');
assert.equal(formatQuantity(5, 'min'), '5分');
assert.equal(formatQuantity(1, 'h'), '1時間');
assert.equal(formatQuantity(95, 'kt'), '95\u00a0kt');
} finally {
setLocale('en');
}
});
+4 -4
View File
@@ -77,7 +77,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
assert.equal(f.frames.size, 0);
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 7);
assert.equal(f.stages.size, 6);
assert.ok(
Object.values(f.effects.stages).every(
(stage) => !stage.enabled && stage.uniforms.intensity === 0,
@@ -85,7 +85,7 @@ test('construction is inert and initialization creates one owned pipeline', () =
);
f.effects.initStyles();
f.effects.initPostProcess();
assert.equal(f.stages.size, 7);
assert.equal(f.stages.size, 6);
f.effects.destroy();
});
@@ -252,7 +252,7 @@ test('stop revokes pending work immediately but retains stages until final destr
assert.equal(f.frames.size, 0);
assert.equal(
f.stages.size,
7,
6,
'Context and Cockpit may still be releasing these stages',
);
const intensity = f.effects.stages.retro.uniforms.intensity;
@@ -281,7 +281,7 @@ test('destroying one instance does not remove another pipeline or clock', () =>
a.effects.destroy();
b.tick(250);
assert.equal(a.stages.size, 0);
assert.equal(b.stages.size, 6);
assert.equal(b.stages.size, 5);
assert.equal(b.effects.stages.retro.uniforms.intensity, 0.5);
b.effects.destroy();
});
+2 -2
View File
@@ -73,13 +73,13 @@ function shortcuts() {
return { documentRef, searchInput, calls, controller, press };
}
test('number keys retain the seven style mappings', () => {
test('number keys retain the six style mappings', () => {
const f = shortcuts();
for (const key of ['1', '2', '3', '4', '5', '6', '7', '8', 'Space'])
f.press(key);
assert.deepEqual(
f.calls,
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'].map(
['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir'].map(
(style) => ['setStyle', style],
),
);
-3
View File
@@ -1,7 +1,6 @@
import { retroShader } from '../styles/retro.js';
import { animeShader } from '../styles/anime.js';
import { noirShader } from '../styles/noir.js';
import { snowShader } from '../styles/snow.js';
import { nightVisionShader } from '../styles/surveillance.js';
import { thermalShader } from '../styles/thermal.js';
import { BLOOM_INTENSITY_DEFAULT } from '../bloom.js';
@@ -16,7 +15,6 @@ export const STYLES = {
thermal: thermalShader,
anime: animeShader,
noir: noirShader,
snow: snowShader,
};
/**
@@ -156,5 +154,4 @@ export const STYLE_STATUS_LABELS = {
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
};
+4 -2
View File
@@ -1,6 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { syncScopeControls } from './displayControls.js';
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
@@ -1272,8 +1273,9 @@ export class VisualSettings {
const superseded = () => typeof isCurrent === 'function' && !isCurrent();
if (superseded()) return false;
if (state.style && state.style !== this.activeStyle) {
this.setStyle(state.style, { applyPreset: false });
const style = RETIRED_STYLES.includes(state.style) ? 'normal' : state.style;
if (style && style !== this.activeStyle) {
this.setStyle(style, { applyPreset: false });
}
const bloomState = state.bloom || {};