mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
commit
188a6086b8
114 files changed
+20791
-407
No files matched your search
@@ -146,6 +146,12 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
id: 3,
|
||||
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
|
||||
});
|
||||
// A view kept from before Snow was retired still applies, on Normal.
|
||||
send(parent, {
|
||||
type: 'gev:view',
|
||||
id: 4,
|
||||
view: { camera: { lat: 1, lon: 2 }, style: 'snow' },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(posted[1], {
|
||||
type: 'gev:view-applied',
|
||||
@@ -161,6 +167,13 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
name === 'set_layer_visibility' && args.layerId === 'flights',
|
||||
),
|
||||
);
|
||||
assert.equal(posted[3].id, 4);
|
||||
assert.equal(posted[3].ok, true);
|
||||
assert.ok(
|
||||
calls.some(
|
||||
([name, args]) => name === 'set_visual_style' && args.style === 'normal',
|
||||
),
|
||||
);
|
||||
assert.equal(posted.filter((message) => message.id === 1).length, 0);
|
||||
// Ready names no data and goes to any parent; answers go only to the
|
||||
// origin that asked.
|
||||
@@ -168,6 +181,7 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
'*',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
'https://host.example',
|
||||
]);
|
||||
remove();
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
|
||||
|
||||
test('celestial ring is available only in Normal style', () => {
|
||||
assert.equal(isCelestialRingStyleSupported('normal'), true);
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
|
||||
for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir']) {
|
||||
assert.equal(isCelestialRingStyleSupported(style), false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
|
||||
);
|
||||
assert.match(
|
||||
setVisionMode.toString(),
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),\s*snow: t\('SNOW'\),?\s*\};/,
|
||||
/const labels = \{\s*optical: t\('NORMAL'\),\s*crt: t\('CRT'\),\s*nvg: t\('NVG'\),\s*thermal: t\('FLIR'\),\s*anime: t\('ANIME'\),\s*noir: t\('NOIR'\),?\s*\};/,
|
||||
);
|
||||
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
|
||||
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
|
||||
|
||||
@@ -5,7 +5,6 @@ export const COCKPIT_VISION_MODES = Object.freeze([
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
|
||||
const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
@@ -14,7 +13,6 @@ const TARGET_STYLE_BY_MODE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
@@ -24,7 +22,6 @@ const COCKPIT_MODE_BY_STYLE = Object.freeze({
|
||||
thermal: 'thermal',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
|
||||
|
||||
@@ -23,7 +23,6 @@ test('Cockpit vision order mirrors the unique map preset order', () => {
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
|
||||
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
|
||||
@@ -36,7 +35,7 @@ test('Cockpit entry selects the vision mode equivalent to the active map style',
|
||||
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
|
||||
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
|
||||
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
|
||||
assert.equal(cockpitVisionModeForStyle('snow'), 'optical');
|
||||
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
|
||||
});
|
||||
|
||||
|
||||
@@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
|
||||
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
|
||||
const sprite = (show) => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
show,
|
||||
color: makeColor(1),
|
||||
});
|
||||
const visible = sprite(true);
|
||||
const hidden = Array.from({ length: 50 }, () => sprite(false));
|
||||
const records = [visible, ...hidden].map((billboard) => ({
|
||||
position: billboard.position,
|
||||
billboard,
|
||||
}));
|
||||
const target = {
|
||||
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
|
||||
paddingPx: 0,
|
||||
cameraDistance: 1000,
|
||||
};
|
||||
let projected = 0;
|
||||
let measured = 0;
|
||||
const apply = (nowMs) =>
|
||||
applyVesselFocusDeemphasis({
|
||||
records,
|
||||
target,
|
||||
nowMs,
|
||||
screenPositionFor: () => {
|
||||
projected += 1;
|
||||
return { x: 50, y: 50 };
|
||||
},
|
||||
cameraDistanceFor: () => {
|
||||
measured += 1;
|
||||
return 1200;
|
||||
},
|
||||
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
|
||||
});
|
||||
|
||||
apply(0);
|
||||
const result = apply(300);
|
||||
|
||||
// Only the on-screen sprite pays for projection and distance.
|
||||
assert.equal(projected, 2);
|
||||
assert.equal(measured, 2);
|
||||
assert.equal(visible.color.alpha, 0.25);
|
||||
assert.equal(result.activeCount, 1);
|
||||
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
|
||||
});
|
||||
@@ -875,6 +875,13 @@ test('route numbers follow the page language and never break from their unit', (
|
||||
);
|
||||
const list = directionsStepList({ route });
|
||||
assert.equal(list.items[1].lead, '240\u00a0м');
|
||||
|
||||
// Japanese unit words follow the number: 1時間5分, but 1.5 km.
|
||||
setLocale('ja');
|
||||
assert.equal(routeDistanceText(1500), '1.5\u00a0km');
|
||||
assert.equal(routeDurationText(39), '39秒');
|
||||
assert.equal(routeDurationText(3900), '1時間5分');
|
||||
assert.equal(routeDurationText(7200), '2時間');
|
||||
});
|
||||
|
||||
test('ramp and lane wording stays localized after choosing the maneuver form', (t) => {
|
||||
@@ -892,6 +899,12 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
'Toma la salida bruscamente a la derecha',
|
||||
'Toma el carril izquierdo',
|
||||
],
|
||||
[
|
||||
'ja',
|
||||
'ランプを斜め左に進んでI-95に入る',
|
||||
'出口を急角度で右に出る',
|
||||
'左車線を通行',
|
||||
],
|
||||
[
|
||||
'zh-CN',
|
||||
'向左前方驶入匝道,进入 I-95',
|
||||
@@ -919,3 +932,19 @@ test('ramp and lane wording stays localized after choosing the maneuver form', (
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the reroute tooltip keeps German mode nouns capitalized', (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
const title = (id) =>
|
||||
directionsRowControls(idle).chips.find((chip) => chip.id === id).title;
|
||||
assert.equal(title('mode-foot'), 'Walk — reroute for walk');
|
||||
setLocale('de');
|
||||
assert.equal(
|
||||
title('mode-foot'),
|
||||
'Zu Fuß – Route im Modus „Zu Fuß“ neu berechnen',
|
||||
);
|
||||
assert.equal(
|
||||
title('mode-bike'),
|
||||
'Fahrrad – Route im Modus „Fahrrad“ neu berechnen',
|
||||
);
|
||||
});
|
||||
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
|
||||
return _focusEvidenceNowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a sprite holds no dimming state, or has settled at full emphasis
|
||||
* with nothing pending. Advancing such a sprite without a screen position (a
|
||||
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
|
||||
* skip it.
|
||||
* @param {object} sprite
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function spriteFocusAtRest(sprite) {
|
||||
const state = _spriteStates.get(sprite);
|
||||
return (
|
||||
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
|
||||
);
|
||||
}
|
||||
|
||||
/** Forget state when a primitive is removed from its owning collection. */
|
||||
export function forgetSpriteFocus(sprite) {
|
||||
if (sprite) _spriteStates.delete(sprite);
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* jam keeps a size bump so queues read as fat beads.
|
||||
* - `crt` (retro): saturated primaries that survive 10-level posterization
|
||||
* plus an all-bucket size boost to out-shout the pixel grid.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns null/0 —
|
||||
* - `normal` (normal/anime/unknown): every lookup returns null/0 —
|
||||
* the shipped palette and sizing apply untouched.
|
||||
*
|
||||
* Sim/uncovered dots (bucket null/'sim') are NEVER restyled under any
|
||||
|
||||
@@ -13,7 +13,7 @@ const luma = ([r, g, b]) => (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
|
||||
const BUCKETS = ['free', 'slow', 'jam'];
|
||||
const MONO_STYLES = ['surveillance', 'thermal', 'noir'];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'snow', 'does-not-exist', '', undefined, null];
|
||||
const NORMAL_STYLES = ['normal', 'anime', 'does-not-exist', '', undefined, null];
|
||||
|
||||
test('profile mapping: NVG/FLIR/noir are mono, retro is crt, everything else normal', () => {
|
||||
for (const s of MONO_STYLES) assert.equal(trafficStyleProfile(s), 'mono', s);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
* black sprite with a light ring is what a hot object looks like there.
|
||||
* - `crt` (retro): saturated per-mode colours that survive posterisation,
|
||||
* a size boost to out-shout the pixel grid, and a thinner halo.
|
||||
* - `normal` (normal/anime/snow/unknown): every lookup returns the shipped
|
||||
* - `normal` (normal/anime/unknown): every lookup returns the shipped
|
||||
* value — mode colour, shipped size, hairline edge only.
|
||||
*
|
||||
* Brackets are a different matter. The detection canvas composites ABOVE the
|
||||
|
||||
@@ -17,7 +17,6 @@ test('profile mapping: NVG/FLIR/noir and cockpit nvg are mono, retro is crt, eve
|
||||
for (const plain of [
|
||||
'normal',
|
||||
'anime',
|
||||
'snow',
|
||||
'minecraft',
|
||||
'',
|
||||
null,
|
||||
|
||||
@@ -135,6 +135,15 @@ test('document byte, nesting, collection, finite-number and string bounds are en
|
||||
);
|
||||
});
|
||||
|
||||
test('a shot saved with the retired Snow style migrates to Normal', () => {
|
||||
const project = fixture();
|
||||
project.scenes[0].shots[0].visual = { style: 'snow' };
|
||||
const migrated = normalizeProject(
|
||||
parseSceneDocument(JSON.stringify(project)),
|
||||
);
|
||||
assert.equal(migrated.scenes[0].shots[0].visual.style, 'normal');
|
||||
});
|
||||
|
||||
test('captured scope and extended detection edits survive migration', () => {
|
||||
const project = fixture();
|
||||
project.scenes[0].shots[0].visual = {
|
||||
|
||||
@@ -1040,6 +1040,19 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
'rotate_globe',
|
||||
'stop',
|
||||
]);
|
||||
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
|
||||
const visualStyle = legacyTools.find(
|
||||
(tool) => tool.name === 'set_visual_style',
|
||||
).parameters.properties.style;
|
||||
assert.deepEqual(visualStyle.enum, [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
const block = JSON.stringify(legacyTools);
|
||||
// Re-derived for the voice layer manifest (generated layer enums, alias and
|
||||
// field hints), point-and-ask's pointer/referent arguments and the prompt
|
||||
|
||||
@@ -76,7 +76,6 @@ const MODE_LABELS = Object.freeze({
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
snow: msg('SNOW'),
|
||||
});
|
||||
|
||||
/** Region ids from _regionLabel() and the name shown for each. */
|
||||
|
||||
@@ -9,10 +9,11 @@ import { unitLabel } from './ui/unitLabels.js';
|
||||
import { localizePlaceName } from './ui/placeNames.js';
|
||||
|
||||
/**
|
||||
* Scripts without letter case keep a space before the unit: `13 km`, `13 كم`.
|
||||
* Arabic would otherwise run the digits into the word.
|
||||
* Scripts without letter case keep a space before the unit: `13 km` in
|
||||
* Chinese and Japanese, `13 كم` in Arabic, which would otherwise run the
|
||||
* digits into the word.
|
||||
*/
|
||||
const UNCASED_UNITS = /^(?:zh-|ar$)/;
|
||||
const UNCASED_UNITS = /^(?:zh|ja|ar)(?:-|$)/;
|
||||
|
||||
/** An upper-case HUD quantity such as `13KM` / `13КМ`, in the active language. */
|
||||
export function hudQuantity(value, unit, digits = 0) {
|
||||
|
||||
+77
-2
@@ -5,6 +5,7 @@ import {
|
||||
applyDocumentTranslations,
|
||||
formatDate,
|
||||
formatNumber,
|
||||
formatRelativeTime,
|
||||
msg,
|
||||
persistLocaleAndReload,
|
||||
setLocale,
|
||||
@@ -33,8 +34,23 @@ function memoryStorage(initial = {}) {
|
||||
test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
|
||||
assert.equal(normalizeLocale('ru-RU'), 'ru');
|
||||
assert.equal(normalizeLocale(' RU '), 'ru');
|
||||
assert.equal(normalizeLocale('uk'), 'uk');
|
||||
assert.equal(normalizeLocale('uk-UA'), 'uk');
|
||||
assert.equal(normalizeLocale('en_GB'), 'en');
|
||||
assert.equal(normalizeLocale('de'), 'de');
|
||||
assert.equal(normalizeLocale('de-AT'), 'de');
|
||||
assert.equal(normalizeLocale('de_CH'), 'de');
|
||||
assert.equal(normalizeLocale('fr'), 'fr');
|
||||
assert.equal(normalizeLocale('fr-CA'), 'fr');
|
||||
assert.equal(normalizeLocale('FR_be'), 'fr');
|
||||
assert.equal(normalizeLocale('pt-BR'), 'pt');
|
||||
assert.equal(normalizeLocale('pt_PT'), 'pt');
|
||||
assert.equal(normalizeLocale('xx'), null);
|
||||
assert.equal(normalizeLocale('it-IT'), 'it');
|
||||
assert.equal(normalizeLocale('it_CH'), 'it');
|
||||
assert.equal(normalizeLocale('ja'), 'ja');
|
||||
assert.equal(normalizeLocale('ja-JP'), 'ja');
|
||||
assert.equal(normalizeLocale('JA_jp'), 'ja');
|
||||
// Explicit script tags take precedence over regional defaults.
|
||||
assert.equal(normalizeLocale('zh'), 'zh-CN');
|
||||
assert.equal(normalizeLocale('zh-CN'), 'zh-CN');
|
||||
@@ -71,7 +87,7 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
|
||||
resolveLocale({
|
||||
location: { search: '?lang=xx' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de-DE', 'ru-RU'],
|
||||
languages: ['ko-KR', 'ru-RU'],
|
||||
}),
|
||||
'ru',
|
||||
);
|
||||
@@ -79,7 +95,15 @@ test('resolveLocale: ?lang= wins, then the stored choice, then browser languages
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['de'],
|
||||
languages: ['de-DE', 'ru-RU'],
|
||||
}),
|
||||
'de',
|
||||
);
|
||||
assert.equal(
|
||||
resolveLocale({
|
||||
location: { search: '' },
|
||||
storage: memoryStorage(),
|
||||
languages: ['ko'],
|
||||
}),
|
||||
'en',
|
||||
);
|
||||
@@ -149,6 +173,36 @@ test('tn() picks English and Russian plural forms', () => {
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
|
||||
});
|
||||
|
||||
test('tn() picks the Portuguese one, many and other forms', () => {
|
||||
const label = (count) =>
|
||||
tn(
|
||||
count,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
);
|
||||
setLocale('pt');
|
||||
assert.equal(label(0), 'ATIVAR · 0 CHAVE PENDENTE');
|
||||
assert.equal(label(1), 'ATIVAR · 1 CHAVE PENDENTE');
|
||||
assert.equal(label(3), 'ATIVAR · 3 CHAVES PENDENTES');
|
||||
assert.equal(label(1_000_000), 'ATIVAR · 1000000 DE CHAVES PENDENTES');
|
||||
});
|
||||
|
||||
test('tn() picks the four Ukrainian plural forms', () => {
|
||||
const label = (count) =>
|
||||
tn(
|
||||
count,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
);
|
||||
setLocale('uk');
|
||||
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/);
|
||||
assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/);
|
||||
});
|
||||
|
||||
test('t() context separates identical English with different meanings', () => {
|
||||
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
|
||||
setLocale('ru');
|
||||
@@ -171,7 +225,13 @@ test('Intl formatters follow the active locale', () => {
|
||||
assert.equal(formatNumber(1234.5), '1,234.5');
|
||||
setLocale('ru');
|
||||
assert.equal(formatDate(date, options), '15 января 2026 г.');
|
||||
assert.equal(formatRelativeTime(-5, 'minute'), '5 минут назад');
|
||||
assert.equal(formatNumber(1234.5), '1 234,5');
|
||||
// Japanese keeps a number and its unit together, as the catalog does.
|
||||
setLocale('ja');
|
||||
assert.equal(formatDate(date, options), '2026年1月15日');
|
||||
assert.equal(formatRelativeTime(-5, 'minute'), '5分前');
|
||||
assert.equal(formatRelativeTime(-3, 'day'), '3日前');
|
||||
});
|
||||
|
||||
function fakeElement({ text = '', attributes = {} } = {}) {
|
||||
@@ -207,6 +267,21 @@ test('applyDocumentTranslations translates opted-in text and attributes in place
|
||||
assert.equal(unknown.textContent, 'No such message');
|
||||
});
|
||||
|
||||
test('applyDocumentTranslations reads data-i18n-context for element text', () => {
|
||||
const option = fakeElement({
|
||||
text: 'All',
|
||||
attributes: { 'data-i18n-context': 'radio tag' },
|
||||
});
|
||||
const doc = {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '[data-i18n]' ? [option] : [],
|
||||
};
|
||||
setLocale('pt');
|
||||
applyDocumentTranslations(doc);
|
||||
assert.equal(option.textContent, t('All', { context: 'radio tag' }));
|
||||
assert.notEqual(option.textContent, t('All'));
|
||||
});
|
||||
|
||||
test('persistLocaleAndReload keeps the choice in ?lang when storage is blocked', () => {
|
||||
const calls = [];
|
||||
const location = {
|
||||
|
||||
+27
-4
@@ -19,15 +19,27 @@ import {
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
import ar from './locales/ar.json' with { type: 'json' };
|
||||
import de from './locales/de.json' with { type: 'json' };
|
||||
import es from './locales/es.json' with { type: 'json' };
|
||||
import fr from './locales/fr.json' with { type: 'json' };
|
||||
import it from './locales/it.json' with { type: 'json' };
|
||||
import ja from './locales/ja.json' with { type: 'json' };
|
||||
import pt from './locales/pt.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import uk from './locales/uk.json' with { type: 'json' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
|
||||
|
||||
const CATALOGS = Object.freeze({
|
||||
ar,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
it,
|
||||
ja,
|
||||
pt,
|
||||
ru,
|
||||
uk,
|
||||
'zh-CN': zhCN,
|
||||
'zh-Hant': zhHant,
|
||||
});
|
||||
@@ -237,13 +249,19 @@ export function formatUtcDayMonth(value) {
|
||||
|
||||
/**
|
||||
* Locale-aware relative time (Intl.RelativeTimeFormat), e.g.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад'.
|
||||
* formatRelativeTime(-5, 'minute') → '5 minutes ago' / '5 минут назад' /
|
||||
* '5分前'.
|
||||
*/
|
||||
export function formatRelativeTime(value, unit = 'second', options) {
|
||||
return cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
const text = cachedFormatter('relative', Intl.RelativeTimeFormat, {
|
||||
numeric: 'auto',
|
||||
...options,
|
||||
}).format(value, unit);
|
||||
// CLDR spaces Japanese (5 分前); the ja catalog writes the number and its
|
||||
// unit together (5分前), so the two must not differ on one screen.
|
||||
return currentLocale === 'ja'
|
||||
? text.replace(/(?<=\d) (?=\p{Script=Han})/gu, '')
|
||||
: text;
|
||||
}
|
||||
|
||||
/** Collapse markup whitespace so a message reads the same in HTML and JS. */
|
||||
@@ -257,7 +275,9 @@ function normalizeMarkupText(text) {
|
||||
* Markup opt-in: the element's own English text or attribute is the source.
|
||||
* <span data-i18n>Normal</span>
|
||||
* <button title="Pin" data-i18n-title>…</button>
|
||||
* Also data-i18n-aria-label and data-i18n-placeholder.
|
||||
* Also data-i18n-aria-label and data-i18n-placeholder. data-i18n-context
|
||||
* gives the element's text a context, as t(text, { context }) does:
|
||||
* <option data-i18n data-i18n-context="radio tag">All</option>
|
||||
*/
|
||||
const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([
|
||||
['data-i18n', null],
|
||||
@@ -282,7 +302,10 @@ export function applyDocumentTranslations(doc) {
|
||||
? element.getAttribute(attribute)
|
||||
: normalizeMarkupText(element.textContent);
|
||||
if (!source) continue;
|
||||
const text = t(source);
|
||||
const context = attribute
|
||||
? null
|
||||
: element.getAttribute?.('data-i18n-context');
|
||||
const text = context ? t(source, { context }) : t(source);
|
||||
if (text === source) continue;
|
||||
if (attribute) element.setAttribute(attribute, text);
|
||||
else element.textContent = text;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from './index.js';
|
||||
import {
|
||||
cockpitUnitCase,
|
||||
formatCockpitDistance,
|
||||
} from '../ui/cockpitPresentation.js';
|
||||
|
||||
test('Japanese cockpit distances keep the unit in its own case', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatCockpitDistance(14000), '14 km');
|
||||
assert.equal(formatCockpitDistance(9500), '9.5 km');
|
||||
assert.equal(cockpitUnitCase('km'), 'km');
|
||||
setLocale('en');
|
||||
assert.equal(formatCockpitDistance(14000), '14 KM');
|
||||
assert.equal(cockpitUnitCase('km'), 'KM');
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from './index.js';
|
||||
import { hudQuantity, LOCALIZED_LOCALITY } from '../hudLocalityFormat.js';
|
||||
import { formatCockpitDistance } from '../ui/cockpitPresentation.js';
|
||||
|
||||
// revmain-opus-ja-i1-1: ja has no case, so HUD units keep the catalog case
|
||||
// (km), as the cockpit already does and docs/translations/ja.md requires.
|
||||
test('Japanese HUD units keep their case, like the cockpit', () => {
|
||||
try {
|
||||
setLocale('ja');
|
||||
assert.equal(formatCockpitDistance(14000), '14 km');
|
||||
assert.equal(hudQuantity(13, 'km'), '13 km');
|
||||
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
|
||||
assert.equal(
|
||||
LOCALIZED_LOCALITY.near({ poi: 'Shibuya', city: 'Tokyo', distKm: 13 }),
|
||||
'SHIBUYA(東京)付近 13 km',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -11,8 +11,14 @@ export const DEFAULT_LOCALE = 'en';
|
||||
export const LOCALES = Object.freeze([
|
||||
'en',
|
||||
'ar',
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'uk',
|
||||
'zh-CN',
|
||||
'zh-Hant',
|
||||
]);
|
||||
@@ -21,8 +27,14 @@ export const LOCALES = Object.freeze([
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
ar: 'العربية',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
pt: 'Português (Brasil)',
|
||||
ru: 'Русский',
|
||||
uk: 'Українська',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-Hant': '中文(繁體)',
|
||||
});
|
||||
@@ -51,6 +63,8 @@ export function intlLocale(locale) {
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* 'ar-EG' and 'ar-SA' → 'ar',
|
||||
* 'de-AT' and 'de_CH' → 'de', 'ja-JP' → 'ja',
|
||||
* 'pt-BR' and 'pt-PT' → 'pt' (Brazilian Portuguese),
|
||||
* 'zh', 'zh-CN' and 'zh-Hans-SG' → 'zh-CN';
|
||||
* 'zh-Hant', 'zh-TW', 'zh-HK' and 'zh-MO' → 'zh-Hant'.
|
||||
* @returns {string|null}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -66,8 +66,6 @@
|
||||
"Anime": "Anime",
|
||||
"Apply high-contrast monochrome film-noir grading.": "Aplica una gradación monocroma de alto contraste estilo film noir.",
|
||||
"Noir": "Noir",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "Añade a la escena un tratamiento frío de ventisca blanca.",
|
||||
"Snow": "Nieve",
|
||||
"MAP SOURCE": "FUENTE DEL MAPA",
|
||||
"Map source": "Fuente del mapa",
|
||||
"Style": "Estilo",
|
||||
@@ -2228,6 +2226,8 @@
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY no está configurada",
|
||||
"The Google Maps JavaScript API could not load": "No se pudo cargar la API de Maps JavaScript de Google",
|
||||
"Weather request timed out": "La petición del clima agotó el tiempo de espera",
|
||||
"Cyclone request timed out": "La petición de ciclones agotó el tiempo de espera",
|
||||
"Malformed cyclone snapshot": "Instantánea de ciclones mal formada",
|
||||
"Malformed weather manifest": "Manifiesto del clima mal formado",
|
||||
"Weather HTTP {status}": "Clima: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Imagen del clima: HTTP {status}",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -417,7 +417,6 @@
|
||||
"Thermal": "Тепловизор",
|
||||
"Anime": "Аниме",
|
||||
"Noir": "Нуар",
|
||||
"Snow": "Снег",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "Текущий стиль обзора кабины: {style}. Активируйте, чтобы переключить на следующий.",
|
||||
"Current style: {style} — click for next": "Текущий стиль: {style} — нажмите для следующего",
|
||||
"AIRCRAFT": "САМОЛЁТ",
|
||||
@@ -691,7 +690,6 @@
|
||||
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Имитирует тепловой контраст в стиле FLIR. Для цвета увеличьте Ironbow.",
|
||||
"Apply bright cel-shaded color and illustrated outlines.": "Применяет яркие cel-shading-цвета и рисованные контуры.",
|
||||
"Apply high-contrast monochrome film-noir grading.": "Применяет высококонтрастную монохромную цветокоррекцию в стиле нуар.",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "Добавляет в сцену холодный снежный эффект «белой мглы».",
|
||||
"Expand LOCATION": "Развернуть панель «Место»",
|
||||
"Collapse panel": "Свернуть панель",
|
||||
"Pin location tray": "Закрепить панель места",
|
||||
@@ -2264,6 +2262,8 @@
|
||||
"GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY не задан",
|
||||
"The Google Maps JavaScript API could not load": "Не удалось загрузить Google Maps JavaScript API",
|
||||
"Weather request timed out": "Истекло время ожидания запроса погоды",
|
||||
"Cyclone request timed out": "Истекло время ожидания запроса циклонов",
|
||||
"Malformed cyclone snapshot": "Некорректный снимок циклонов",
|
||||
"Malformed weather manifest": "Некорректный манифест погоды",
|
||||
"Weather HTTP {status}": "Погода: HTTP {status}",
|
||||
"Weather image HTTP {status}": "Снимок погоды: HTTP {status}",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1204,6 +1204,8 @@
|
||||
"Some weather tiles unavailable": "部分天气瓦片不可用",
|
||||
"Malformed weather manifest": "天气清单格式错误",
|
||||
"Weather request timed out": "天气请求超时",
|
||||
"Cyclone request timed out": "气旋请求超时",
|
||||
"Malformed cyclone snapshot": "气旋快照格式错误",
|
||||
"Temperature field unavailable": "温度场不可用",
|
||||
"Mean sea level pressure field unavailable": "平均海平面气压场不可用",
|
||||
"Wind upstream unavailable": "风场上游数据源不可用",
|
||||
@@ -1625,7 +1627,6 @@
|
||||
"Thermal": "热成像",
|
||||
"Anime": "动漫",
|
||||
"Noir": "黑白",
|
||||
"Snow": "雪景",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "当前座舱视觉样式:{style}。点击可切换到下一个样式。",
|
||||
"Current style: {style} — click for next": "当前样式:{style}——点击切换到下一个",
|
||||
"Collapse Contact panel": "收起目标面板",
|
||||
@@ -2536,7 +2537,6 @@
|
||||
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模拟 FLIR 风格的热成像对比。调高 Ironbow 可显示色彩。",
|
||||
"Apply bright cel-shaded color and illustrated outlines.": "应用明亮的赛璐珞色彩和插画轮廓线。",
|
||||
"Apply high-contrast monochrome film-noir grading.": "应用高对比度的黑白电影风格调色。",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "为场景添加寒冷的暴雪白化效果。",
|
||||
"MAP SOURCE": "地图源",
|
||||
"Map source": "地图源",
|
||||
"Style": "样式",
|
||||
|
||||
@@ -1199,6 +1199,8 @@
|
||||
"Some weather tiles unavailable": "部分天氣瓦片不可用",
|
||||
"Malformed weather manifest": "天氣清單格式錯誤",
|
||||
"Weather request timed out": "天氣請求超時",
|
||||
"Cyclone request timed out": "氣旋請求超時",
|
||||
"Malformed cyclone snapshot": "氣旋快照格式錯誤",
|
||||
"Temperature field unavailable": "溫度場不可用",
|
||||
"Mean sea level pressure field unavailable": "平均海平面氣壓場不可用",
|
||||
"Wind upstream unavailable": "風場上游數據源不可用",
|
||||
@@ -1615,7 +1617,6 @@
|
||||
"Thermal": "熱成像",
|
||||
"Anime": "動漫",
|
||||
"Noir": "黑白",
|
||||
"Snow": "雪景",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "當前座艙視覺樣式:{style}。點擊可切換到下一個樣式。",
|
||||
"Current style: {style} — click for next": "當前樣式:{style}——點擊切換到下一個",
|
||||
"Collapse Contact panel": "收起目標面板",
|
||||
@@ -2524,7 +2525,6 @@
|
||||
"Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "模擬 FLIR 風格的熱成像對比。調高 Ironbow 可顯示色彩。",
|
||||
"Apply bright cel-shaded color and illustrated outlines.": "應用明亮的賽璐珞色彩和插畫輪廓線。",
|
||||
"Apply high-contrast monochrome film-noir grading.": "應用高對比度的黑白電影風格調色。",
|
||||
"Add a cold, snowy whiteout treatment to the scene.": "為場景添加寒冷的暴雪白化效果。",
|
||||
"MAP SOURCE": "地圖源",
|
||||
"Map source": "地圖源",
|
||||
"Style": "樣式",
|
||||
|
||||
@@ -33,6 +33,172 @@
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CCTV",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"Details",
|
||||
"Dubai",
|
||||
"EXPORT",
|
||||
"EXPORT {slot}",
|
||||
"FALLBACK",
|
||||
"FILTER",
|
||||
"FLIR",
|
||||
"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",
|
||||
"Hybrid",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LIVE",
|
||||
"Landsat 8/9",
|
||||
"Latin",
|
||||
"Layout",
|
||||
"London",
|
||||
"Los Angeles",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MISSION",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"Metal",
|
||||
"Minimal",
|
||||
"NAME",
|
||||
"NASA FIRMS · LIVE",
|
||||
"NAV",
|
||||
"NEWS",
|
||||
"NNW",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"NW",
|
||||
"New York",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OK",
|
||||
"ORBIT",
|
||||
"ORBIT · ",
|
||||
"Oldies",
|
||||
"OpenStreetMap + optional Google Maps Places",
|
||||
"Operator",
|
||||
"PAUSE",
|
||||
"PLAY",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"POSITION {age}",
|
||||
"PROJ MONITOR",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Pin",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"REGION",
|
||||
"RESET",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSW",
|
||||
"STANDARD",
|
||||
"START",
|
||||
"STATION",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"STD",
|
||||
"SW",
|
||||
"SYNC",
|
||||
"San Francisco",
|
||||
"Scanlines",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"T minus {seconds}",
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
"TEMP",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"Turboprop",
|
||||
"UPSTREAM",
|
||||
"Upstream HTTP {status}",
|
||||
"Vignette",
|
||||
"WHOT/BHOT",
|
||||
"WIND",
|
||||
"WNW",
|
||||
"WSW",
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"km",
|
||||
"km/h",
|
||||
"min",
|
||||
"mph",
|
||||
"radio|NEWS",
|
||||
"radio|TALK",
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM DOWNRANGE",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
@@ -158,6 +324,531 @@
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"fr": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Action",
|
||||
"Allocation",
|
||||
"Altitude {altitude}",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"Aviation / Marine",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Bus",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CALIBRATION",
|
||||
"CARGO",
|
||||
"CAUSE",
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"COCKPIT",
|
||||
"COMMERCIAL",
|
||||
"CONTACT",
|
||||
"CONTACTS",
|
||||
"CONTACTS · 250 KM",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyan",
|
||||
"Cyber",
|
||||
"Cyclones · NHC / CPHC",
|
||||
"DENSE",
|
||||
"DENSE ···",
|
||||
"DENSE ✕",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"DESTINATION",
|
||||
"Dance",
|
||||
"Drone",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"EUROPE",
|
||||
"FLIR",
|
||||
"FM MHZ",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Ferry",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GAIN",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Gain",
|
||||
"Google Street View",
|
||||
"Grain",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"IMAGE",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"LOCAL",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MISSION",
|
||||
"MM",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"Minimal",
|
||||
"Minutes",
|
||||
"Mode",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"New York",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OFF",
|
||||
"OK",
|
||||
"ON",
|
||||
"ON/OFF",
|
||||
"Oldies",
|
||||
"PAUSE",
|
||||
"POSITION {age}",
|
||||
"PROJ OFF",
|
||||
"PROJECTION OFF",
|
||||
"PROJECTION ON",
|
||||
"PROPAGATION",
|
||||
"Paris",
|
||||
"Pause",
|
||||
"Plan",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SIG",
|
||||
"SOURCE",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STATION",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Satellites",
|
||||
"Saturation",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"Source",
|
||||
"Station",
|
||||
"Station {id}",
|
||||
"Style",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"TYPE",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Tokyo",
|
||||
"Train",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"image",
|
||||
"installation class|BASE",
|
||||
"installation class|INSTALLATION",
|
||||
"km",
|
||||
"km/h",
|
||||
"max {value} MW",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"satellite",
|
||||
"standard",
|
||||
"station",
|
||||
"voice|OFF",
|
||||
"{count} MSGS",
|
||||
"{count} SOURCES",
|
||||
"{count} messages",
|
||||
"{n} cyclones",
|
||||
"{n} satellites",
|
||||
"{rate} msg/s",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"it": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AFRICA",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
|
||||
"ANIME",
|
||||
"ASIA",
|
||||
"AUTO",
|
||||
"AUTO HOP OFF",
|
||||
"AUTO HOP ON",
|
||||
"Alternative",
|
||||
"Ambient",
|
||||
"Anime",
|
||||
"Area",
|
||||
"Austin",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"Business jet",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CARGO",
|
||||
"CCTV",
|
||||
"CCTV OFF",
|
||||
"CCTV ON",
|
||||
"CH {index} / {total}",
|
||||
"COCKPIT",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"Cyber",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"DISPLAY",
|
||||
"Dance",
|
||||
"Drone",
|
||||
"Dubai",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"FLIR",
|
||||
"FM MHZ",
|
||||
"FOCUS",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"GEO",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Google Street View",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Hip-Hop",
|
||||
"House",
|
||||
"Indie",
|
||||
"Ironbow",
|
||||
"Jazz",
|
||||
"KTS",
|
||||
"LIVE",
|
||||
"Landsat 8/9",
|
||||
"Layout",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"NASA FIRMS · LIVE",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NVG",
|
||||
"New York",
|
||||
"Noir",
|
||||
"OCEANIA",
|
||||
"OFF",
|
||||
"OK",
|
||||
"ON",
|
||||
"ON/OFF",
|
||||
"Oldies",
|
||||
"PAYLOAD",
|
||||
"PLAY",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"PRECIP",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RADIO",
|
||||
"REC",
|
||||
"RESET",
|
||||
"RESET CAL",
|
||||
"Radio",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STD",
|
||||
"STOP",
|
||||
"SYNC",
|
||||
"San Francisco",
|
||||
"Scope",
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Tokyo",
|
||||
"Tram",
|
||||
"Trance",
|
||||
"UPSTREAM",
|
||||
"USB {rate} msg/s",
|
||||
"Upstream HTTP {status}",
|
||||
"V/S {rate} FPM",
|
||||
"VIEWSHED",
|
||||
"VIEWSHED ON",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"Washington DC",
|
||||
"World",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"ft",
|
||||
"hPa",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
"max {value} MW",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"radio|TALK",
|
||||
"satellite",
|
||||
"standard",
|
||||
"view band|METRO",
|
||||
"voice|OFF",
|
||||
"{count} MSGS",
|
||||
"{count} feeds live",
|
||||
"{count} scenes",
|
||||
"{rate} msg/s",
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{speed}KT",
|
||||
"{value} KM",
|
||||
"{value} KM DOWNRANGE",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"{value} conf",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CH {index} / {total}",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"FLT",
|
||||
"FM MHZ",
|
||||
"FOV",
|
||||
"FOV {value}°",
|
||||
"FT",
|
||||
"GEO",
|
||||
"HDG",
|
||||
"HDG {value}°",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"MSG/S",
|
||||
"NVG",
|
||||
"POS: {time}Z",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"dB",
|
||||
"ft",
|
||||
"hPa",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"pt": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"ALT {altitude} FT · GS {speed} KT · TRK {track}",
|
||||
"ALTITUDE",
|
||||
"ALTITUDE · FT",
|
||||
"ANIME",
|
||||
"AUTO",
|
||||
"Altitude {altitude}",
|
||||
"Anime",
|
||||
"Austin",
|
||||
"BIKE",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"Bloom",
|
||||
"Blues",
|
||||
"CAL {value}",
|
||||
"CAL · {badge}",
|
||||
"CCTV",
|
||||
"CRS {value}°",
|
||||
"CRT",
|
||||
"Country",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"Dance",
|
||||
"Drone",
|
||||
"Dubai",
|
||||
"ENE",
|
||||
"ESE",
|
||||
"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",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Layout",
|
||||
"Los Angeles",
|
||||
"MIC",
|
||||
"MIL",
|
||||
"MINI",
|
||||
"MM",
|
||||
"MONITOR",
|
||||
"MSG/S",
|
||||
"Metal",
|
||||
"NAV",
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
"Normal",
|
||||
"OCEANIA",
|
||||
"OK",
|
||||
"POS: {time}Z",
|
||||
"PRECIP",
|
||||
"Paris",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"RANGE",
|
||||
"REC",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"Sentinel-2",
|
||||
"Sentinel-2 via HLS",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
"V/S {rate}",
|
||||
"VISUAL",
|
||||
"VOLUME",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"dB",
|
||||
"fire confidence|nominal",
|
||||
"ft",
|
||||
"hPa",
|
||||
"installation class|BASE",
|
||||
"km",
|
||||
"km/h",
|
||||
"kt",
|
||||
"min",
|
||||
"mini",
|
||||
"mph",
|
||||
"nominal",
|
||||
"view band|GLOBAL",
|
||||
"{rate} msg/s",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
"{value} KM/H",
|
||||
"{value} KM/S",
|
||||
"ΔN",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"ru": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
@@ -176,6 +867,19 @@
|
||||
"VS",
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
"AIS: {name}",
|
||||
"CCTV",
|
||||
"CRT",
|
||||
"FLIR",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"WHOT/BHOT"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
|
||||
@@ -17,6 +17,9 @@ test('Chinese HUD and cockpit units keep their case without spacing coordinates'
|
||||
assert.equal(formatCockpitDistance(14000), '14\u00a0km');
|
||||
assert.equal(LOCALIZED_LOCALITY.sector({ latDeg: 21.33, lonDeg: -77.5 }),
|
||||
'扇区 21.33N 77.50W');
|
||||
setLocale('ja');
|
||||
assert.equal(hudQuantity(13, 'km'), '13 km');
|
||||
assert.equal(hudQuantity(1.5, 'km', 1), '1.5 km');
|
||||
setLocale('en');
|
||||
assert.equal(hudQuantity(0, 'km'), '0KM');
|
||||
assert.equal(hudQuantity(2069, 'km', 1), '2069.0KM');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The CCTV HUD summary shows the CAL badge, source kind and view band in the
|
||||
// UI language; English keeps its codes, and unknown source codes stay codes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
|
||||
function summary(sourceKind) {
|
||||
const camera = {
|
||||
id: 'c1',
|
||||
city: 'Austin',
|
||||
name: 'Cam',
|
||||
rangeM: 100,
|
||||
fovDeg: 60,
|
||||
feedType: 'image',
|
||||
};
|
||||
const record = { camera };
|
||||
const presentation = createPresentation({
|
||||
state: {
|
||||
_records: [record],
|
||||
_healthById: new Map(sourceKind ? [['c1', { sourceKind }]] : []),
|
||||
_showProjection: false,
|
||||
_coverageMode: 'sector',
|
||||
},
|
||||
services: {},
|
||||
parts: {
|
||||
selection: { getActiveRecord: () => record },
|
||||
model: {
|
||||
sectorAreaKm2: () => 1,
|
||||
currentViewContext: () => 'street:1:2',
|
||||
},
|
||||
geometry: { coverageNeighborCount: () => 0 },
|
||||
calibration: { deriveCalBadge: () => 'raw-prior' },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return presentation.buildSummaryText();
|
||||
}
|
||||
|
||||
test('en: CAL, SRC and CONTEXT keep their English codes', () => {
|
||||
setLocale('en');
|
||||
assert.match(summary('upstream'), /CAL RAW PRIOR · SRC UPSTREAM/);
|
||||
assert.match(summary(null), /SRC IMAGE · STREET CONTEXT/);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
});
|
||||
|
||||
test('fr: CAL, SRC and CONTEXT values are translated', () => {
|
||||
setLocale('fr');
|
||||
try {
|
||||
assert.match(
|
||||
summary('upstream'),
|
||||
/CAL ESTIM\. INITIALE · SRC AMONT · CONTEXTE RUE/,
|
||||
);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { isolate, t, translateKnown } from '../../i18n/index.js';
|
||||
import { isolate, msg, t } from '../../i18n/index.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
@@ -8,8 +8,45 @@ import {
|
||||
isDatasheetFov,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
/** The translated name of a camera view band (street, city, regional, global). */
|
||||
function viewBandLabel(band) {
|
||||
// The summary line names the CAL badge, the source kind and the view band in
|
||||
// the page language; the codes underneath stay English.
|
||||
const CAL_BADGE_LABELS = Object.freeze({
|
||||
calibrated: msg('CALIBRATED'),
|
||||
curated: msg('CURATED'),
|
||||
'raw-prior': msg('RAW PRIOR'),
|
||||
});
|
||||
|
||||
// The same source-kind words as the CCTV panel (src/ui/cctvFrames.js).
|
||||
const SOURCE_KIND_LABELS = Object.freeze({
|
||||
snapshot: msg('SNAPSHOT'),
|
||||
live: msg('LIVE'),
|
||||
fallback: msg('FALLBACK'),
|
||||
upstream: msg('UPSTREAM'),
|
||||
synthetic: msg('SYNTHETIC'),
|
||||
configured: msg('CONFIGURED'),
|
||||
seed: msg('SEED'),
|
||||
image: msg('IMAGE'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
/** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */
|
||||
export function summaryCalLabel(badge) {
|
||||
const key = String(badge || '');
|
||||
return CAL_BADGE_LABELS[key]
|
||||
? t(CAL_BADGE_LABELS[key])
|
||||
: key.replace('-', ' ').toUpperCase();
|
||||
}
|
||||
|
||||
/** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */
|
||||
export function summarySourceLabel(kind) {
|
||||
const key = String(kind || 'image').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** View band id ('street', 'city'…) as the HUD names it. */
|
||||
export function summaryViewBandLabel(band) {
|
||||
switch (band) {
|
||||
case 'street':
|
||||
return t('STREET', { context: 'view band' });
|
||||
@@ -20,7 +57,7 @@ function viewBandLabel(band) {
|
||||
case 'global':
|
||||
return t('GLOBAL', { context: 'view band' });
|
||||
default:
|
||||
return String(band).toUpperCase();
|
||||
return String(band || '').toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +127,13 @@ export function createPresentation({
|
||||
: t('ISOLATED VIEW'),
|
||||
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
|
||||
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
|
||||
t('CAL {value}', {
|
||||
value: translateKnown(calBadge.replace('-', ' ').toUpperCase()),
|
||||
}),
|
||||
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
|
||||
t('SRC {value}', {
|
||||
value: translateKnown(
|
||||
String(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
).toUpperCase(),
|
||||
value: summarySourceLabel(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
),
|
||||
}),
|
||||
t('{value} CONTEXT', { value: viewBandLabel(viewBand) }),
|
||||
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
|
||||
// Identified hardware: append the model's datasheet specs + attribution.
|
||||
active.camera.spec
|
||||
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The CCTV summary line names the CAL badge, the source kind and the view
|
||||
// band in the page language; unknown codes stay as uppercase codes.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
summaryCalLabel,
|
||||
summarySourceLabel,
|
||||
summaryViewBandLabel,
|
||||
} from './presentation.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
test('English keeps the summary codes', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'RAW PRIOR');
|
||||
assert.equal(summarySourceLabel('image'), 'IMAGE');
|
||||
assert.equal(summarySourceLabel('hls'), 'HLS');
|
||||
assert.equal(summaryViewBandLabel('street'), 'STREET');
|
||||
assert.equal(summaryViewBandLabel('none'), 'NONE');
|
||||
});
|
||||
|
||||
test('Portuguese summary values are translated', () => {
|
||||
inLocale('pt', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'ESTIMATIVA BRUTA');
|
||||
assert.equal(summaryCalLabel('calibrated'), 'CALIBRADA');
|
||||
assert.equal(summarySourceLabel('IMAGE'), 'IMAGEM');
|
||||
assert.equal(summarySourceLabel('mjpeg'), 'MJPEG');
|
||||
assert.equal(summaryViewBandLabel('street'), 'RUA');
|
||||
assert.equal(summaryViewBandLabel('city'), 'CIDADE');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
// Shared with the AI tools, so no i18n import: these errors stay English here
|
||||
// and the cyclones layer translates them where it shows them (translateKnown).
|
||||
const msg = (text) => text;
|
||||
|
||||
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
||||
const malformed = () => new Error('Malformed cyclone snapshot');
|
||||
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
|
||||
const text = (value, max = 160) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
@@ -215,7 +219,7 @@ export function createCycloneSource({
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Cyclone request timed out')),
|
||||
() => controller.abort(new Error(msg('Cyclone request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
t,
|
||||
msg,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
formatQuantity,
|
||||
quantitySeparator,
|
||||
unitLabel,
|
||||
} from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -125,7 +135,9 @@ export function routeDurationText(seconds) {
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
if (!rest) return hours;
|
||||
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
|
||||
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
@@ -394,6 +406,16 @@ export function normalizeDirectionsParams(params = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A travel-mode word inside a sentence: lower case, except in German, where
|
||||
* the words are nouns and keep their capital ("Auto", "Fahrrad").
|
||||
* @param {string} word Translated mode word.
|
||||
* @returns {string}
|
||||
*/
|
||||
function modeWordInSentence(word) {
|
||||
return getLocale() === 'de' ? word : word.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
|
||||
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
|
||||
@@ -409,7 +431,7 @@ export function directionsRowControls(state) {
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
modeLower: modeWordInSentence(t(spec.word)),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,7 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { state } = vesselState;
|
||||
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
const { ensureGeoidReady } = services.geoid;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
@@ -125,6 +125,7 @@ export function createLifecycle({
|
||||
state.interactionHandlerFactory = null;
|
||||
state.interactionKeyTarget = null;
|
||||
state.preRenderRemover = null;
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
state.lastVisibilityUpdate = 0;
|
||||
state.lastFocusUpdate = 0;
|
||||
state.activeFocusCount = 0;
|
||||
@@ -164,7 +165,6 @@ export function createLifecycle({
|
||||
const wasEnabled = state.feed.enabled;
|
||||
state.feed.enabled = true;
|
||||
if (!wasEnabled) beginAisSession();
|
||||
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
|
||||
const activeViewer = viewer || state.viewer;
|
||||
components.rendering.ensureCollections(activeViewer);
|
||||
components.selection.installInteraction(activeViewer);
|
||||
@@ -208,6 +208,7 @@ export function createLifecycle({
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
components.rendering.setVisible(false);
|
||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||
@@ -227,6 +228,7 @@ export function createLifecycle({
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
if (state.feed.abort) state.feed.abort.abort();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
components.selection.clearVesselInspection();
|
||||
|
||||
@@ -29,7 +29,11 @@ function services() {
|
||||
forgetSpriteFocus: noop,
|
||||
},
|
||||
worldFocus: {},
|
||||
render: { releaseContinuousRender: noop },
|
||||
render: {
|
||||
holdContinuousRender: noop,
|
||||
releaseContinuousRender: noop,
|
||||
governorRequestRender: noop,
|
||||
},
|
||||
};
|
||||
}
|
||||
function setup(source, options = {}) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVesselLayer } from './index.js';
|
||||
import { VISIBILITY_UPDATE_MS } from './policy.js';
|
||||
|
||||
// Vessels move only when a poll lands, so the layer must let the scene idle:
|
||||
// it asks for frames for forced passes and one trailing frame after the camera
|
||||
// stops, and never schedules a frame from a pass the camera did not cause.
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function setup() {
|
||||
let clock = 10_000;
|
||||
const requests = [];
|
||||
const holds = new Set();
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const layer = createVesselLayer({
|
||||
source: { getSnapshot: async () => ({ records: [] }) },
|
||||
services: {
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer: noop,
|
||||
registerEntityContext: () => null,
|
||||
},
|
||||
trails: {
|
||||
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
|
||||
},
|
||||
labels: {},
|
||||
picking: { unregisterPickOwner: noop },
|
||||
overlay: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
geoid: {},
|
||||
sprites: {},
|
||||
focus: {
|
||||
focusNowMs: () => clock,
|
||||
getFocusTarget: () => null,
|
||||
focusPassIsNeeded: () => false,
|
||||
forgetSpriteFocus: noop,
|
||||
},
|
||||
worldFocus: {},
|
||||
render: {
|
||||
holdContinuousRender: (id) => holds.add(id),
|
||||
releaseContinuousRender: (id) => holds.delete(id),
|
||||
governorRequestRender: (reason) => requests.push(reason),
|
||||
},
|
||||
},
|
||||
});
|
||||
const camera = {
|
||||
positionWC: { x: 0, y: 0, z: 7_000_000 },
|
||||
heading: 0,
|
||||
pitch: -1.2,
|
||||
roll: 0,
|
||||
};
|
||||
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
|
||||
layer.testing._setAisRuntimeForTest({
|
||||
now: () => clock,
|
||||
setTimeout(fn, ms) {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, { fn, at: clock + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
const advance = (ms) => {
|
||||
clock += ms;
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.at > clock) continue;
|
||||
timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
};
|
||||
const frame = () => layer.testing._runVisibilityPassForTest();
|
||||
return { layer, camera, requests, holds, timers, advance, frame };
|
||||
}
|
||||
|
||||
test('a parked camera schedules no frames after the cadence pass', () => {
|
||||
const { requests, holds, timers, advance, frame } = setup();
|
||||
frame();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
advance(100);
|
||||
frame();
|
||||
}
|
||||
assert.deepEqual(requests, []);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(holds.has('ais-vessels'), false);
|
||||
});
|
||||
|
||||
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
|
||||
const { layer, camera, requests, timers, advance, frame } = setup();
|
||||
frame();
|
||||
advance(100);
|
||||
camera.heading = 0.5;
|
||||
frame();
|
||||
advance(100);
|
||||
frame();
|
||||
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
|
||||
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
|
||||
|
||||
advance(VISIBILITY_UPDATE_MS);
|
||||
assert.deepEqual(requests, ['ais-vessels-visibility']);
|
||||
// The trailing frame runs the regular pass on the pose the camera stopped
|
||||
// on. Its own frames must not schedule another one.
|
||||
frame();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
advance(100);
|
||||
frame();
|
||||
}
|
||||
assert.deepEqual(requests, ['ais-vessels-visibility']);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('a forced pass requests its own frame', () => {
|
||||
const { layer, requests } = setup();
|
||||
layer.testing._runVisibilityPassForTest(true);
|
||||
assert.deepEqual(requests, ['ais-vessels-update']);
|
||||
});
|
||||
|
||||
test('destroy cancels a pending trailing frame', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
});
|
||||
const { layer, camera, requests, timers, advance, frame } = setup();
|
||||
frame();
|
||||
advance(100);
|
||||
camera.heading = 0.5;
|
||||
frame();
|
||||
assert.equal(timers.size, 1);
|
||||
layer.destroy();
|
||||
assert.equal(timers.size, 0);
|
||||
advance(VISIBILITY_UPDATE_MS);
|
||||
assert.deepEqual(requests, []);
|
||||
});
|
||||
@@ -75,7 +75,13 @@ export function createRendering({
|
||||
focusPassIsNeeded,
|
||||
advanceSpriteFocus,
|
||||
focusAlphaNeedsWrite,
|
||||
spriteFocusAtRest,
|
||||
} = services.focus;
|
||||
const {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} = services.render;
|
||||
|
||||
function renderRowLimit() {
|
||||
const configured = Number(options.maxRows);
|
||||
@@ -196,6 +202,14 @@ export function createRendering({
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Vessels move only when a poll lands, so the layer does not hold the
|
||||
// continuous render loop. Its passes run in preRender, which idle mode fires
|
||||
// only for rendered frames: a forced pass (new data, selection) requests its
|
||||
// frame, a frame that moved the camera but fell inside the 800 ms cadence
|
||||
// leaves a trailing frame so the view the camera stopped on is always
|
||||
// evaluated, and the continuous hold is kept only while focus emphasis is
|
||||
// animating. The trailing frame is keyed to camera motion alone: the pass's
|
||||
// own label publication requests a frame, and scheduling on that would loop.
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
@@ -203,8 +217,27 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleTrailingVisibilityPass(now) {
|
||||
if (state.trailingVisibilityTimer) return;
|
||||
const waitMs = Math.max(
|
||||
0,
|
||||
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
|
||||
);
|
||||
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
|
||||
state.trailingVisibilityTimer = null;
|
||||
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
|
||||
}, waitMs);
|
||||
}
|
||||
|
||||
function cancelTrailingVisibilityPass() {
|
||||
if (!state.trailingVisibilityTimer) return;
|
||||
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
|
||||
state.trailingVisibilityTimer = null;
|
||||
}
|
||||
|
||||
function updateVisibility(force = false) {
|
||||
if (!state.feed.enabled) return;
|
||||
if (force) governorRequestRender('ais-vessels-update');
|
||||
const now = focusNowMs(performance.now());
|
||||
const focusTarget = getFocusTarget();
|
||||
const regularPass =
|
||||
@@ -212,13 +245,30 @@ export function createRendering({
|
||||
const focusPass =
|
||||
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
|
||||
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
||||
if (
|
||||
!regularPass &&
|
||||
state.viewer?.camera &&
|
||||
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
|
||||
)
|
||||
scheduleTrailingVisibilityPass(now);
|
||||
if (!regularPass && !focusPass) return;
|
||||
if (regularPass) state.lastVisibilityUpdate = now;
|
||||
if (focusPass) state.lastFocusUpdate = now;
|
||||
if (!state.records.all.length) {
|
||||
// No records — flush any lingering card entries (vanished-feed case).
|
||||
if (regularPass) updateClusteredLabels([]);
|
||||
if (focusPass) state.activeFocusCount = 0;
|
||||
// Still record the pose this pass evaluated, or every later frame that
|
||||
// falls inside the cadence would schedule another trailing frame.
|
||||
if (regularPass) {
|
||||
if (state.viewer?.camera)
|
||||
vesselState._lastCamPoseSig = cameraPoseSignature(
|
||||
state.viewer.camera,
|
||||
);
|
||||
updateClusteredLabels([]);
|
||||
}
|
||||
if (focusPass) {
|
||||
state.activeFocusCount = 0;
|
||||
releaseContinuousRender('ais-vessels');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -272,6 +322,9 @@ export function createRendering({
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
});
|
||||
state.activeFocusCount = result.activeCount;
|
||||
if (result.activeCount > 0 || result.transitioning)
|
||||
holdContinuousRender('ais-vessels');
|
||||
else releaseContinuousRender('ais-vessels');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +355,16 @@ export function createRendering({
|
||||
const bb = visual?.billboard;
|
||||
const position = bb?.position || visual?.position;
|
||||
if (!bb || !position) continue;
|
||||
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
|
||||
// no pending focus state and full alpha would advance to exactly what it
|
||||
// already is, so skip it instead of paying its distance and state work
|
||||
// every 80 ms while anything is tracked.
|
||||
if (
|
||||
bb.show === false &&
|
||||
spriteFocusAtRest(bb) &&
|
||||
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
|
||||
)
|
||||
continue;
|
||||
const focus = advanceSpriteFocus(bb, {
|
||||
// Hidden/far-side sprites still finish any pending release so the active
|
||||
// count remains truthful and they cannot reappear with stale dim alpha.
|
||||
@@ -510,6 +573,7 @@ export function createRendering({
|
||||
shipIcon,
|
||||
installRuntime,
|
||||
updateVisibility,
|
||||
cancelTrailingVisibilityPass,
|
||||
applyVesselFocusDeemphasis,
|
||||
makeOccluder,
|
||||
isVisible,
|
||||
|
||||
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
|
||||
/** Test-only key target paired with interactionHandlerFactory. */
|
||||
interactionKeyTarget: null,
|
||||
preRenderRemover: null,
|
||||
/** Pending timer that renders one frame after a cadence-skipped pass. */
|
||||
trailingVisibilityTimer: null,
|
||||
lastVisibilityUpdate: 0,
|
||||
lastFocusUpdate: 0,
|
||||
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
|
||||
|
||||
@@ -151,6 +151,18 @@ export function createTesting({
|
||||
};
|
||||
}
|
||||
|
||||
/** Run one preRender visibility/focus pass. Test-only seam. */
|
||||
|
||||
function _runVisibilityPassForTest(force = false) {
|
||||
components.rendering.updateVisibility(force);
|
||||
}
|
||||
|
||||
/** True while a trailing visibility frame is scheduled. Test-only seam. */
|
||||
|
||||
function _hasTrailingVisibilityPassForTest() {
|
||||
return state.trailingVisibilityTimer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lifecycle ownership state without exposing the mutable state object.
|
||||
* Test-only seam.
|
||||
@@ -176,5 +188,7 @@ export function createTesting({
|
||||
_setAisRuntimeForTest,
|
||||
_getVesselFeedStateForTest,
|
||||
_getVesselStateForTest,
|
||||
_runVisibilityPassForTest,
|
||||
_hasTrailingVisibilityPassForTest,
|
||||
};
|
||||
}
|
||||
@@ -239,6 +239,19 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'Direct the camera like a drone operator: orbit the current view target, pan, tilt, or rotate — one bounded nudge (mode=once) or continuous motion until stopped (mode=continuous). Continuous motion also stops on any manual camera input or when a navigation tool runs. Say the RESULTING state when confirming ("Orbiting slowly").';
|
||||
camera.parameters.properties.direction.description =
|
||||
'Required except for orbit (defaults right/clockwise) and stop.';
|
||||
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
|
||||
const visualStyle = unchanged.find(
|
||||
(tool) => tool.name === 'set_visual_style',
|
||||
).parameters.properties.style;
|
||||
assert.deepEqual(visualStyle.enum, [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
assert.equal(unchanged.length, 14);
|
||||
const digest = createHash('sha256')
|
||||
.update(JSON.stringify(unchanged))
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { SCENE_RECIPES, displayShotTitle } from './recipes.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
test('an operator-typed title in the built-in Nepal scene stays as typed', () => {
|
||||
const scene = SCENE_RECIPES.find((r) => r.id === 'bhote-koshi-nepal-scene');
|
||||
assert.ok(scene, 'built-in bootstrap scene exists');
|
||||
for (const locale of ['ru', 'es', 'it', 'zh-CN', 'zh-Hant']) {
|
||||
setLocale(locale);
|
||||
const title = 'Global Incident Context';
|
||||
// A shot captured by the operator: no sourcePackId, renamed to this title.
|
||||
const shown = displayShotTitle(
|
||||
title,
|
||||
{ id: 'shot-user', title },
|
||||
{ id: scene.id },
|
||||
);
|
||||
assert.equal(shown, title, `${locale}: got ${shown}`);
|
||||
}
|
||||
});
|
||||
@@ -712,6 +712,10 @@ test('a legacy three-shot Nepal browser project bootstraps to the current 25-sho
|
||||
assert.deepEqual(scene.shots.slice(0, 3).map(({ camera }) => camera), originalCameras);
|
||||
assert.equal(scene.appliedShotPacks[0].id, 'bhote-koshi-nepal-evidence-pack');
|
||||
assert.equal(scene.appliedShotPacks[0].version, 18);
|
||||
// displayShotTitle trusts these bindings to localize the bootstrap shots.
|
||||
for (const shot of scene.shots.slice(0, 8)) {
|
||||
assert.equal(scene.appliedShotPacks[0].shotBindings[shot.title], shot.id);
|
||||
}
|
||||
assert.equal(director._selectedSceneId, scene.id);
|
||||
assert.equal(director._selectedShotId, scene.shots[0].id);
|
||||
} finally {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
BLOOM_SCALE_VERSION,
|
||||
decodeBloomIntensity,
|
||||
} from '../bloom.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
|
||||
/** @constant {number} Current schema version for project migration */
|
||||
export const PROJECT_VERSION = SCENE_DOCUMENT_VERSION;
|
||||
@@ -262,7 +263,9 @@ export function normalizeShot(
|
||||
...(rawShot?.dataPackIds ? { dataPackIds: [...rawShot.dataPackIds] } : {}),
|
||||
...(rawShot?.move ? { move: deepClone(rawShot.move) } : {}),
|
||||
visual: {
|
||||
style: visual.style || 'normal',
|
||||
style: RETIRED_STYLES.includes(visual.style)
|
||||
? 'normal'
|
||||
: visual.style || 'normal',
|
||||
bloom: normalizeBloomState(bloom, {
|
||||
projectVersion,
|
||||
fallbackIntensity: 50,
|
||||
|
||||
+24
-16
@@ -1062,8 +1062,9 @@ export function displaySceneTitle(scene) {
|
||||
* typed. Without `shot`, the title alone decides.
|
||||
* @param {string} title
|
||||
* @param {{id?: string, sourcePackId?: string}} [shot]
|
||||
* @param {{id?: string, appliedShotPacks?: object[]}} [scene] The shot's
|
||||
* scene, for projects saved before shots recorded their source pack.
|
||||
* @param {{id?: string, appliedShotPacks?: Array<{id?: string, shotBindings?: Object<string, string>}>}} [scene]
|
||||
* The shot's scene, for projects saved before shots recorded their source
|
||||
* pack.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function displayShotTitle(title, shot = undefined, scene = undefined) {
|
||||
@@ -1076,21 +1077,28 @@ export function displayShotTitle(title, shot = undefined, scene = undefined) {
|
||||
return numbered ? t('Shot {n}', { n: numbered[1] }) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the built-in recipe behind a scene declares a shot with this title:
|
||||
* its own keyframes, or a bootstrap shot of a shot pack applied to the scene.
|
||||
* Those shots carry no sourcePackId; the pack marker binds the title to that
|
||||
* very shot, so another shot typed with the same title stays as typed.
|
||||
*/
|
||||
/** Whether the built-in recipe behind a scene declares this shot's title. */
|
||||
function recipeDeclaresShotTitle(scene, shot, title) {
|
||||
const recipe = scene?.id ? getSceneRecipeById(scene.id) : null;
|
||||
if (recipe?.cameraPath?.some((keyframe) => keyframe?.title === title))
|
||||
return true;
|
||||
const packs = Array.isArray(scene?.appliedShotPacks)
|
||||
? scene.appliedShotPacks
|
||||
: [];
|
||||
return (
|
||||
Boolean(shot?.id) &&
|
||||
packs.some((marker) => marker?.shotBindings?.[title] === shot.id)
|
||||
if (!recipe) return false;
|
||||
const declares = (path) =>
|
||||
Array.isArray(path) && path.some((keyframe) => keyframe?.title === title);
|
||||
if (declares(recipe.cameraPath)) return true;
|
||||
// A bootstrap recipe seeds legacy "Shot N" keyframes; the upgrade replaces
|
||||
// them with the append recipe's bootstrap shots, which carry no source pack.
|
||||
// The pack marker binds each of those titles to its shot id, so a shot the
|
||||
// operator later gave the same title stays as typed.
|
||||
return SCENE_APPEND_RECIPES.some(
|
||||
(append) =>
|
||||
append.legacySceneBootstrap?.targetSceneTitle === recipe.title &&
|
||||
declares(append.legacySceneBootstrap.cameraPath) &&
|
||||
Boolean(shot?.id) &&
|
||||
(Array.isArray(scene.appliedShotPacks)
|
||||
? scene.appliedShotPacks
|
||||
: []
|
||||
).some(
|
||||
(marker) =>
|
||||
marker?.id === append.id && marker.shotBindings?.[title] === shot.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
SCENE_APPEND_RECIPES,
|
||||
SCENE_RECIPES,
|
||||
displaySceneTitle,
|
||||
displayShotTitle,
|
||||
@@ -87,31 +88,6 @@ test('a built-in shot title localizes only for a recipe-sourced shot', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('bootstrap shots of an applied shot pack localize their built-in titles', () => {
|
||||
setLocale('ru');
|
||||
const title = 'Global Incident Context';
|
||||
const shot = { id: 'shot-1', title };
|
||||
// A legacy scene upgraded in place: its bootstrap shots have no
|
||||
// sourcePackId, but the scene's pack marker binds the title to the shot.
|
||||
const upgraded = {
|
||||
id: 'scene-legacy',
|
||||
appliedShotPacks: [
|
||||
{
|
||||
id: 'bhote-koshi-nepal-evidence-pack',
|
||||
version: 12,
|
||||
shotBindings: { [title]: 'shot-1' },
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
displayShotTitle(title, shot, upgraded),
|
||||
displayShotTitle(title),
|
||||
);
|
||||
assert.notEqual(displayShotTitle(title, shot, upgraded), title);
|
||||
// The same typed title in a scene without the pack stays as typed.
|
||||
assert.equal(displayShotTitle(title, shot, { id: 'scene-user' }), title);
|
||||
});
|
||||
|
||||
test('the Scene status line shows an operator-typed shot title as typed', async () => {
|
||||
const { createScenePlaybackAdapter } = await import('./playbackAdapter.js');
|
||||
const statuses = [];
|
||||
@@ -166,3 +142,40 @@ test('the scene context of a shot carries its display title by provenance', asyn
|
||||
title,
|
||||
);
|
||||
});
|
||||
|
||||
test('bootstrap shots of a built-in scene localize without a source pack', () => {
|
||||
setLocale('ru');
|
||||
const append = SCENE_APPEND_RECIPES.find(
|
||||
(recipe) => recipe.legacySceneBootstrap?.cameraPath?.length,
|
||||
);
|
||||
if (!append) return;
|
||||
const bootstrap = append.legacySceneBootstrap;
|
||||
const scene = SCENE_RECIPES.find(
|
||||
(recipe) => recipe.title === bootstrap.targetSceneTitle,
|
||||
);
|
||||
if (!scene) return;
|
||||
for (const { title } of bootstrap.cameraPath) {
|
||||
// The upgrade copies these keyframes into the scene without sourcePackId
|
||||
// and binds each title to its shot in the pack marker.
|
||||
const upgraded = {
|
||||
id: scene.id,
|
||||
appliedShotPacks: [
|
||||
{ id: append.id, version: 1, shotBindings: { [title]: 'shot-old' } },
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
displayShotTitle(title, { id: 'shot-old', title }, upgraded),
|
||||
displayShotTitle(title),
|
||||
);
|
||||
// Another shot the operator gave the same title stays as typed.
|
||||
assert.equal(
|
||||
displayShotTitle(title, { id: 'shot-user', title }, upgraded),
|
||||
title,
|
||||
);
|
||||
// The same title in an operator's own scene stays as typed.
|
||||
assert.equal(
|
||||
displayShotTitle(title, { id: 'shot-user', title }, { id: 'scene-user' }),
|
||||
title,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { displayShotTitle } from './recipes.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
test('a typed shot title in a user scene with an applied pack stays as typed', () => {
|
||||
setLocale('ru');
|
||||
const title = 'Global Incident Context';
|
||||
// The user's own shot (not from the pack: no sourcePackId) in a user scene
|
||||
// that later received the Nepal evidence pack.
|
||||
const ownShot = { id: 'user-shot-7', title };
|
||||
const scene = {
|
||||
id: 'scene-user-123',
|
||||
appliedShotPacks: [{ id: 'bhote-koshi-nepal-evidence-pack', version: 12 }],
|
||||
};
|
||||
assert.equal(displayShotTitle(title, ownShot, scene), title);
|
||||
});
|
||||
@@ -107,6 +107,13 @@ test('the HAL example survives the round trip and shrinks to 39 characters', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('a long-form link to the retired Snow style opens as Normal', () => {
|
||||
const snow = parseLegacy('v=2&lat=1&lon=2&style=snow&sp=d.90_w.20');
|
||||
const normal = parseLegacy('v=2&lat=1&lon=2');
|
||||
assert.equal(snow.style, 'normal');
|
||||
assert.deepEqual(snow.styleParams, normal.styleParams);
|
||||
});
|
||||
|
||||
test('links from earlier long-form versions keep their historical meaning', () => {
|
||||
for (const hash of [
|
||||
'lat=10&lon=20',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '../data/detectionPolicy.js';
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { VIEW_STYLES } from '../view/index.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
import {
|
||||
GLOBAL_POST_DEFAULTS,
|
||||
STYLE_PRESET_DEFAULTS,
|
||||
@@ -101,7 +102,7 @@ export const SHARE_VIEW_ENUMS = Object.freeze({
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
'snow', // retired (RETIRED_STYLES): decodes as Normal
|
||||
]),
|
||||
mapStack: Object.freeze([
|
||||
'photoreal',
|
||||
@@ -221,10 +222,6 @@ export const SHARE_VIEW_STYLE_PARAMS = Object.freeze({
|
||||
{ key: 'grainAmt', token: 'g', min: 0, max: 1 },
|
||||
{ key: 'vignetteAmt', token: 'v', min: 0, max: 1 },
|
||||
]),
|
||||
snow: freezeAll([
|
||||
{ key: 'density', token: 'd', min: 0, max: 1 },
|
||||
{ key: 'wind', token: 'w', min: 0, max: 1 },
|
||||
]),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1321,8 +1318,12 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
|
||||
return { ok: false, reason: 'out-of-range' };
|
||||
}
|
||||
|
||||
// A retired style's code stays reserved: the view opens on Normal, and
|
||||
// parameters written for it are dropped rather than reported as unknown.
|
||||
const retiredStyle = RETIRED_STYLES.includes(raw.style);
|
||||
if (raw.style !== undefined && !VIEW_STYLES.includes(raw.style)) {
|
||||
if (get('style').len) unknown.code('enum:style', raw.style);
|
||||
if (get('style').len && !retiredStyle)
|
||||
unknown.code('enum:style', raw.style);
|
||||
raw.style = undefined;
|
||||
}
|
||||
for (const key of ['hudVariant', 'mapStack']) {
|
||||
@@ -1357,7 +1358,7 @@ export function decodeShareView(code, { nowMs = Date.now() } = {}) {
|
||||
unknown.code('layer-option', 'without-layers');
|
||||
if (get('panelState'))
|
||||
raw.panelState = decodePanels(get('panelState').len, unknown);
|
||||
if (get('styleParams'))
|
||||
if (get('styleParams') && !retiredStyle)
|
||||
raw.styleParams = decodeStyleParams(
|
||||
get('styleParams').len,
|
||||
raw.style ?? 'normal',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
LAYER_STATE_REGISTRY,
|
||||
LAYER_STATE_TOKEN_ALPHABET,
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
REGISTERED_LAYER_IDS,
|
||||
createDefaultLayerState,
|
||||
@@ -440,7 +441,8 @@ test('defaults travel as frozen meanings that match today’s app defaults', ()
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.hudVariant.slice(0, HUD_LAYOUTS.length), [
|
||||
...HUD_LAYOUTS,
|
||||
]);
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES]);
|
||||
// Snow is retired; its code stays reserved (see the retired-style test).
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.style, [...VIEW_STYLES, 'snow']);
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.mapStack, [...VIEW_MAPS]);
|
||||
assert.deepEqual(SHARE_VIEW_ENUMS.detectionAllocation, [
|
||||
...ALLOCATION_STRATEGIES,
|
||||
@@ -549,7 +551,6 @@ test('the field registry is well formed and permanent', () => {
|
||||
...common,
|
||||
styleParams: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 },
|
||||
},
|
||||
snow: { ...common, styleParams: { density: 0.6, wind: 0.5 } },
|
||||
});
|
||||
assert.deepEqual(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, e.kind]),
|
||||
@@ -966,6 +967,29 @@ test('enum names outside the code tables travel as text', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('links to the retired Snow style open as Normal', () => {
|
||||
const snowCode = SHARE_VIEW_ENUMS.style.indexOf('snow');
|
||||
assert.equal(snowCode, 6, 'a retired code is never reused');
|
||||
const params = new WireWriter()
|
||||
.varint(LAYER_STATE_TOKEN_ALPHABET.indexOf('d'))
|
||||
.varint(90)
|
||||
.varint(LAYER_STATE_TOKEN_ALPHABET.indexOf('w'))
|
||||
.varint(20)
|
||||
.finish();
|
||||
for (const writeStyle of [
|
||||
(writer) => writer.uintField(14, snowCode),
|
||||
(writer) => writer.textField(14, 'snow'),
|
||||
]) {
|
||||
const writer = new WireWriter();
|
||||
writer.bytes.push(1);
|
||||
writeStyle(writer);
|
||||
writer.bytesField(27, params);
|
||||
const { state, unknownFields } = decodeOk(hashOf(writer.finish()));
|
||||
assert.deepEqual(state, normalizeShareViewState({}));
|
||||
assert.equal(unknownFields, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------- integrity ---------------- */
|
||||
|
||||
test('malformed input is rejected with a reason, never thrown', () => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { msg } from '../i18n/marker.js';
|
||||
|
||||
/**
|
||||
* Snow Style — White overlay + Desaturation + Falling particles
|
||||
* Transforms the world into a winter wonderland
|
||||
*/
|
||||
export const snowShader = {
|
||||
name: 'snow',
|
||||
uniforms: {
|
||||
density: { default: 0.6, min: 0, max: 1, label: msg('Density') },
|
||||
wind: { default: 0.5, min: 0, max: 1, label: msg('Wind') },
|
||||
},
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform sampler2D colorTexture;
|
||||
uniform vec2 colorTextureDimensions;
|
||||
uniform float intensity;
|
||||
uniform float time;
|
||||
uniform float density;
|
||||
uniform float wind;
|
||||
in vec2 v_textureCoordinates;
|
||||
|
||||
// Hash function for pseudo-random snow
|
||||
float hash(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
|
||||
// Layered snow particles
|
||||
float snowLayer(vec2 uv, float layer) {
|
||||
float depth = 0.5 + layer * 0.5;
|
||||
float speed = 0.4 + layer * 0.3;
|
||||
float size = mix(0.01, 0.025, layer);
|
||||
float windForce = 0.05 + wind * 0.2;
|
||||
|
||||
vec2 snowUV = uv * vec2(1.0, 0.5) * (4.0 + layer * 4.0);
|
||||
snowUV.y += time * speed;
|
||||
// Offset is the integral of a swaying velocity sin(time * 0.5 + phase) * windForce,
|
||||
// so the sway stays bounded however long the app has been running.
|
||||
snowUV.x -= 2.0 * cos(time * 0.5 + layer * 3.14) * windForce;
|
||||
|
||||
vec2 cell = floor(snowUV);
|
||||
vec2 f = fract(snowUV);
|
||||
|
||||
float snow = 0.0;
|
||||
// Check neighboring cells for smooth particles
|
||||
for (int y = -1; y <= 1; y++) {
|
||||
for (int x = -1; x <= 1; x++) {
|
||||
vec2 neighbor = vec2(float(x), float(y));
|
||||
vec2 point = vec2(hash(cell + neighbor), hash(cell + neighbor + 100.0));
|
||||
// Animate particle position slightly
|
||||
point = 0.5 + 0.4 * sin(time * 0.3 + 6.2831 * point);
|
||||
float d = length(f - neighbor - point);
|
||||
snow += smoothstep(size, 0.0, d) * depth;
|
||||
}
|
||||
}
|
||||
return snow;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = v_textureCoordinates;
|
||||
vec4 color = texture(colorTexture, uv);
|
||||
|
||||
// Cool/blue color shift
|
||||
vec3 cool = color.rgb * vec3(0.85, 0.9, 1.1);
|
||||
|
||||
// Desaturate partially
|
||||
float luma = dot(cool, vec3(0.299, 0.587, 0.114));
|
||||
vec3 desaturated = mix(cool, vec3(luma), 0.4 * intensity);
|
||||
|
||||
// Brighten (snow coverage lightens everything)
|
||||
vec3 brightened = mix(desaturated, vec3(1.0), 0.15 * intensity);
|
||||
|
||||
// Add white frost overlay on brighter areas
|
||||
float frostMask = smoothstep(0.3, 0.8, luma);
|
||||
vec3 frosted = mix(brightened, vec3(0.95, 0.97, 1.0), frostMask * 0.25 * intensity);
|
||||
|
||||
// Accumulate snow particle layers (density controls amount)
|
||||
float snow = 0.0;
|
||||
snow += snowLayer(uv, 0.0) * 0.5;
|
||||
snow += snowLayer(uv, 0.25) * 0.6;
|
||||
snow += snowLayer(uv, 0.5) * 0.7;
|
||||
snow += snowLayer(uv, 0.75) * 0.8;
|
||||
snow += snowLayer(uv, 1.0) * 1.0;
|
||||
snow = clamp(snow * (0.4 + density * 1.2), 0.0, 1.0);
|
||||
|
||||
// Composite snow particles
|
||||
vec3 result = frosted + snow * 0.8 * intensity;
|
||||
|
||||
// Subtle fog in lower areas
|
||||
float fog = smoothstep(0.0, 0.4, 1.0 - uv.y) * 0.1 * intensity;
|
||||
result = mix(result, vec3(0.85, 0.88, 0.95), fog);
|
||||
|
||||
out_FragColor = vec4(mix(color.rgb, result, intensity), color.a);
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -5,7 +5,6 @@ const STYLE_KEYS = Object.freeze({
|
||||
4: 'thermal',
|
||||
5: 'anime',
|
||||
6: 'noir',
|
||||
7: 'snow',
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 || {};
|
||||
|
||||
+7
-4
@@ -11,6 +11,7 @@ import {
|
||||
decodeLayerStateParams,
|
||||
encodeLayerStateParams,
|
||||
} from '../data/layerState.js';
|
||||
import { RETIRED_STYLES } from '../visualStylePolicy.js';
|
||||
|
||||
/** Visual styles by internal name, with the name share links use. */
|
||||
export const STYLE_URL_NAMES = Object.freeze({
|
||||
@@ -20,7 +21,6 @@ export const STYLE_URL_NAMES = Object.freeze({
|
||||
thermal: 'flir',
|
||||
anime: 'anime',
|
||||
noir: 'noir',
|
||||
snow: 'snow',
|
||||
});
|
||||
|
||||
const STYLES_BY_URL_NAME = Object.freeze(
|
||||
@@ -254,6 +254,7 @@ export function createView({
|
||||
} = {}) {
|
||||
if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon))
|
||||
throw new TypeError('A view needs a camera lat and lon');
|
||||
if (RETIRED_STYLES.includes(style)) style = 'normal';
|
||||
if (style !== null && !VIEW_STYLES.includes(style))
|
||||
throw new TypeError(`Unknown view style: ${style}`);
|
||||
if (map !== null && !VIEW_MAPS.includes(map))
|
||||
@@ -370,9 +371,11 @@ export function viewFromParams(params) {
|
||||
pitch_deg: number('pitch'),
|
||||
},
|
||||
layers: state?.enabledLayerIds ?? [],
|
||||
style: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
|
||||
? STYLES_BY_URL_NAME[params.get('style')]
|
||||
: null,
|
||||
style: RETIRED_STYLES.includes(params.get('style'))
|
||||
? 'normal'
|
||||
: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
|
||||
? STYLES_BY_URL_NAME[params.get('style')]
|
||||
: null,
|
||||
map: VIEW_MAPS.includes(map) ? map : null,
|
||||
follow,
|
||||
annotations: annotationsFromParams(params),
|
||||
|
||||
@@ -127,6 +127,14 @@ test('annotations travel in the link, keeping only what the app draws', async ()
|
||||
assert.equal(viewToParams(many).has('an'), false);
|
||||
});
|
||||
|
||||
test('a view naming the retired Snow style opens on Normal', () => {
|
||||
const camera = { lat: 1, lon: 2 };
|
||||
assert.equal(createView({ camera, style: 'snow' }).style, 'normal');
|
||||
const view = viewFromParams(new URLSearchParams('lat=1&lon=2&style=snow'));
|
||||
assert.equal(view.style, 'normal');
|
||||
assert.equal(viewToParams(view).get('style'), 'normal');
|
||||
});
|
||||
|
||||
test('inherited and unknown style keys do not corrupt a restored view', () => {
|
||||
for (const style of [
|
||||
'constructor',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Browser-free style vocabulary shared by views, share links and the UI.
|
||||
* Styles the app no longer offers, by the name share links used: a view,
|
||||
* link, scene or shot that names one opens on Normal instead of keeping
|
||||
* whatever style was already showing.
|
||||
*/
|
||||
export const RETIRED_STYLES = Object.freeze(['snow']);
|
||||
@@ -248,15 +248,7 @@ const schemas = [
|
||||
properties: {
|
||||
style: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
],
|
||||
enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir'],
|
||||
},
|
||||
},
|
||||
required: ['style'],
|
||||
|
||||
@@ -45,7 +45,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// S2 GhostMaps extends the generated layer enums.
|
||||
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
|
||||
// is consolidated without changing either feature's arguments.
|
||||
'9ee82c4214499280c208c93751b19d7b724c81b7727a2d0c6dab0184749fbe15',
|
||||
// Snow leaves the set_visual_style enum.
|
||||
'724a82b538a12a994401fc941e175625b032bfb96109934e091d3ef5b9600909',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -169,6 +170,18 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
// Cyber adds one HUD layout.
|
||||
const hud = property('set_hud').layout;
|
||||
hud.enum = hud.enum.filter((layout) => layout !== 'cyber');
|
||||
// Snow is retired from set_visual_style; restore it to keep the legacy pin.
|
||||
const visualStyle = legacy.find((tool) => tool.name === 'set_visual_style')
|
||||
.parameters.properties.style;
|
||||
assert.deepEqual(visualStyle.enum, [
|
||||
'normal',
|
||||
'retro',
|
||||
'surveillance',
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
]);
|
||||
visualStyle.enum = [...visualStyle.enum, 'snow'];
|
||||
// Derived by applying the same removals to the 4b56d0e9 actionSchemas.
|
||||
assert.equal(
|
||||
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
|
||||
|
||||
@@ -66,7 +66,6 @@ const ALLOWED_STYLES = new Set([
|
||||
'thermal',
|
||||
'anime',
|
||||
'noir',
|
||||
'snow',
|
||||
]);
|
||||
const PANEL_ALIASES = new Map([
|
||||
['data', 'data-panel'],
|
||||
|
||||
+4
-1
@@ -963,7 +963,10 @@ export function progressStepLabel(step, label) {
|
||||
: step
|
||||
? spokenLabel(step, 24)
|
||||
: t('Working');
|
||||
return label ? `${base}: ${spokenLabel(label, 32)}` : base;
|
||||
// The join is a catalog string so each language spaces its colon.
|
||||
return label
|
||||
? t('{name}: {state}', { name: base, state: spokenLabel(label, 32) })
|
||||
: base;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
spokenLabel,
|
||||
} from './speech.js';
|
||||
import { countWords, findHedges } from './speechLint.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
|
||||
function envelope(name, result, args) {
|
||||
return attachVoiceResult(name, result, args);
|
||||
@@ -459,3 +460,15 @@ test('labels are bounded and cleaned before they are spoken', () => {
|
||||
'background steps stay on screen',
|
||||
);
|
||||
});
|
||||
|
||||
test('progress step labels join through the catalog colon', () => {
|
||||
setLocale('fr');
|
||||
try {
|
||||
assert.equal(
|
||||
progressStepLabel('resolve', 'Capitol'),
|
||||
'Recherche de lieux\u202f: Capitol',
|
||||
);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user