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-642-ja
# Conflicts: # docs/CURRENT-STATE.md # docs/TRANSLATORS.md # scripts/package-boundaries.json # src/i18n/i18n.test.mjs # src/i18n/index.js # src/i18n/locale.js # src/i18n/same-as-english.json # src/layers/cctv/presentation.js # src/ui/localePicker.test.mjs
This commit is contained in:
commit
812308d0ee
101 files changed
+17490
-332
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);
|
||||
});
|
||||
@@ -932,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. */
|
||||
|
||||
+67
-2
@@ -34,8 +34,20 @@ 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');
|
||||
@@ -72,7 +84,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',
|
||||
);
|
||||
@@ -80,7 +92,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',
|
||||
);
|
||||
@@ -146,6 +166,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');
|
||||
@@ -210,6 +260,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 = {
|
||||
|
||||
+17
-2
@@ -16,16 +16,26 @@ import {
|
||||
normalizeLocale,
|
||||
writeStoredLocale,
|
||||
} from './locale.js';
|
||||
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({
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
it,
|
||||
ja,
|
||||
pt,
|
||||
ru,
|
||||
uk,
|
||||
'zh-CN': zhCN,
|
||||
'zh-Hant': zhHant,
|
||||
});
|
||||
@@ -234,7 +244,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],
|
||||
@@ -259,7 +271,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;
|
||||
|
||||
+13
-1
@@ -10,9 +10,14 @@ export const DEFAULT_LOCALE = 'en';
|
||||
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
|
||||
export const LOCALES = Object.freeze([
|
||||
'en',
|
||||
'de',
|
||||
'es',
|
||||
'fr',
|
||||
'it',
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'uk',
|
||||
'zh-CN',
|
||||
'zh-Hant',
|
||||
]);
|
||||
@@ -20,9 +25,14 @@ export const LOCALES = Object.freeze([
|
||||
/** Each locale named in its own language, for the selector. */
|
||||
export const LOCALE_NAMES = Object.freeze({
|
||||
en: 'English',
|
||||
de: 'Deutsch',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
it: 'Italiano',
|
||||
ja: '日本語',
|
||||
pt: 'Português (Brasil)',
|
||||
ru: 'Русский',
|
||||
uk: 'Українська',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-Hant': '中文(繁體)',
|
||||
});
|
||||
@@ -31,7 +41,9 @@ export const LOCALE_NAMES = Object.freeze({
|
||||
const RTL_LOCALES = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru', 'ja-JP' → 'ja',
|
||||
* Map any language tag to a shipped locale: 'ru-RU' and 'RU' → 'ru',
|
||||
* '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",
|
||||
@@ -2227,6 +2225,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
@@ -416,7 +416,6 @@
|
||||
"Thermal": "Тепловизор",
|
||||
"Anime": "Аниме",
|
||||
"Noir": "Нуар",
|
||||
"Snow": "Снег",
|
||||
"Current cockpit vision style: {style}. Activate for next style.": "Текущий стиль обзора кабины: {style}. Активируйте, чтобы переключить на следующий.",
|
||||
"Current style: {style} — click for next": "Текущий стиль: {style} — нажмите для следующего",
|
||||
"AIRCRAFT": "САМОЛЁТ",
|
||||
@@ -690,7 +689,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": "Закрепить панель места",
|
||||
@@ -2263,6 +2261,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
@@ -1203,6 +1203,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": "风场上游数据源不可用",
|
||||
@@ -1624,7 +1626,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": "收起目标面板",
|
||||
@@ -2535,7 +2536,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": "样式",
|
||||
|
||||
@@ -1198,6 +1198,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": "風場上游數據源不可用",
|
||||
@@ -1614,7 +1616,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": "收起目標面板",
|
||||
@@ -2523,7 +2524,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": "樣式",
|
||||
|
||||
@@ -1,4 +1,170 @@
|
||||
{
|
||||
"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",
|
||||
@@ -124,6 +290,354 @@
|
||||
"Δ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",
|
||||
@@ -173,6 +687,134 @@
|
||||
"Δ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}",
|
||||
@@ -191,6 +833,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",
|
||||
|
||||
@@ -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 { t } from '../../i18n/index.js';
|
||||
import { 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 camera's zoom bucket (cctv model) in the HUD's view-band words. */
|
||||
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 band.toUpperCase();
|
||||
return String(band || '').toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,17 +125,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: calBadge.replace('-', ' ').toUpperCase(),
|
||||
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
|
||||
t('SRC {value}', {
|
||||
value: summarySourceLabel(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
),
|
||||
}),
|
||||
health?.sourceKind
|
||||
? t('SRC {value}', {
|
||||
value: String(health.sourceKind).toUpperCase(),
|
||||
})
|
||||
: t('SRC {value}', {
|
||||
value: String(active.camera.feedType || 'image').toUpperCase(),
|
||||
}),
|
||||
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,7 +18,13 @@
|
||||
|
||||
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,
|
||||
@@ -400,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
|
||||
@@ -415,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,
|
||||
|
||||
+28
-9
@@ -1061,25 +1061,44 @@ export function displaySceneTitle(scene) {
|
||||
* (`sourcePackId`), so a typed title that matches a built-in one stays as
|
||||
* typed. Without `shot`, the title alone decides.
|
||||
* @param {string} title
|
||||
* @param {{sourcePackId?: string}} [shot]
|
||||
* @param {{id?: string}} [scene] The shot's scene, for projects saved before
|
||||
* shots recorded their source pack.
|
||||
* @param {{id?: string, sourcePackId?: string}} [shot]
|
||||
* @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) {
|
||||
const text = typeof title === 'string' ? title : '';
|
||||
const fromRecipe = shot
|
||||
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene?.id, text)
|
||||
? Boolean(shot.sourcePackId) || recipeDeclaresShotTitle(scene, shot, text)
|
||||
: true;
|
||||
if (fromRecipe && BUILTIN_SHOT_TITLES.has(text)) return t(text);
|
||||
const numbered = /^Shot ([1-9]\d*)$/.exec(text);
|
||||
return numbered ? t('Shot {n}', { n: numbered[1] }) : text;
|
||||
}
|
||||
|
||||
/** Whether the built-in recipe behind a scene declares a shot with this title. */
|
||||
function recipeDeclaresShotTitle(sceneId, title) {
|
||||
const recipe = sceneId ? getSceneRecipeById(sceneId) : null;
|
||||
return Boolean(
|
||||
recipe?.cameraPath?.some((keyframe) => keyframe?.title === title),
|
||||
/** 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) 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,
|
||||
@@ -141,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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'en', 'es', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
['ru', 'uk', 'de', 'en', 'es', 'fr', 'it', 'pt', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -50,7 +53,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['es', 'en', 'ru', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
['es', 'de', 'en', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -60,7 +63,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['en', 'es', 'ru', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
['en', 'de', 'es', 'fr', 'it', 'pt', 'ru', 'uk', 'zh-CN', 'zh-Hant', 'ja'],
|
||||
);
|
||||
const rows = buildLocaleOptions({
|
||||
locales: MANY,
|
||||
@@ -95,6 +98,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,
|
||||
@@ -103,6 +108,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', () => {
|
||||
@@ -165,7 +171,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({
|
||||
@@ -250,6 +259,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'),
|
||||
});
|
||||
@@ -261,9 +271,10 @@ 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, 'ja');
|
||||
assert.equal(model.activeOption.code, 'zh-Hant');
|
||||
model.keydown({ key: 'Home' });
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
model.keydown({ key: 'ArrowUp' });
|
||||
@@ -275,7 +286,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,
|
||||
@@ -292,7 +303,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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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