Merge branch 'main' into upstream/voice-alias-hint-budget

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-29 06:13:18 -07:00
commit 3772aaed0a
21 files changed
+3596 -16

No files matched your search

+47
View File
@@ -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);
});
+15
View File
@@ -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);
+18
View File
@@ -33,6 +33,8 @@ 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('xx'), null);
// Explicit script tags take precedence over regional defaults.
@@ -142,6 +144,22 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
});
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');
+8 -1
View File
@@ -18,10 +18,17 @@ import {
} from './locale.js';
import es from './locales/es.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({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant });
const CATALOGS = Object.freeze({
es,
ru,
uk,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE;
+9 -1
View File
@@ -8,13 +8,21 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']);
export const LOCALES = Object.freeze([
'en',
'es',
'ru',
'uk',
'zh-CN',
'zh-Hant',
]);
/** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({
en: 'English',
es: 'Español',
ru: 'Русский',
uk: 'Українська',
'zh-CN': '中文(简体)',
'zh-Hant': '中文(繁體)',
});
File diff suppressed because it is too large. Load diff
+13
View File
@@ -142,6 +142,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",
+4 -2
View File
@@ -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();
+5 -1
View File
@@ -29,7 +29,11 @@ function services() {
forgetSpriteFocus: noop,
},
worldFocus: {},
render: { releaseContinuousRender: noop },
render: {
holdContinuousRender: noop,
releaseContinuousRender: noop,
governorRequestRender: noop,
},
};
}
function setup(source, options = {}) {
+138
View File
@@ -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, []);
});
+66 -2
View File
@@ -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,
+2
View File
@@ -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. */
+14
View File
@@ -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,
};
}
+6 -5
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
['ru', 'uk', 'en', 'es', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
['es', 'en', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
);
assert.deepEqual(
codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES,
}),
),
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
);
const rows = buildLocaleOptions({
locales: MANY,
@@ -261,7 +261,8 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.openList();
assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en');
// Russian collation puts the other Cyrillic name next.
assert.equal(model.activeOption.code, 'uk');
model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'zh-Hant');
model.keydown({ key: 'Home' });
@@ -275,7 +276,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,
+2 -1
View File
@@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
}
.cockpit-vision-controls {
display: grid;
grid-template-columns: 48px 58px 48px;
/* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */
grid-template-columns: 48px minmax(58px, max-content) 48px;
align-items: stretch;
gap: 2px;
margin-top: 2px;