mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'origin/main' into german/hal-644-ar
# Conflicts: # docs/CURRENT-STATE.md # docs/TRANSLATORS.md # scripts/package-boundaries.json # src/hudLocalityFormat.js # src/i18n/index.js # src/i18n/locale.js # src/i18n/same-as-english.json # src/layers/cctv/presentation.js # src/scenes/recipes.js # src/ui/localePicker.test.mjs # src/ui/unitLabels.js
This commit is contained in:
commit
188a6086b8
114 files changed
+20791
-407
No files matched your search
@@ -0,0 +1,58 @@
|
||||
// The CCTV HUD summary shows the CAL badge, source kind and view band in the
|
||||
// UI language; English keeps its codes, and unknown source codes stay codes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
|
||||
function summary(sourceKind) {
|
||||
const camera = {
|
||||
id: 'c1',
|
||||
city: 'Austin',
|
||||
name: 'Cam',
|
||||
rangeM: 100,
|
||||
fovDeg: 60,
|
||||
feedType: 'image',
|
||||
};
|
||||
const record = { camera };
|
||||
const presentation = createPresentation({
|
||||
state: {
|
||||
_records: [record],
|
||||
_healthById: new Map(sourceKind ? [['c1', { sourceKind }]] : []),
|
||||
_showProjection: false,
|
||||
_coverageMode: 'sector',
|
||||
},
|
||||
services: {},
|
||||
parts: {
|
||||
selection: { getActiveRecord: () => record },
|
||||
model: {
|
||||
sectorAreaKm2: () => 1,
|
||||
currentViewContext: () => 'street:1:2',
|
||||
},
|
||||
geometry: { coverageNeighborCount: () => 0 },
|
||||
calibration: { deriveCalBadge: () => 'raw-prior' },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return presentation.buildSummaryText();
|
||||
}
|
||||
|
||||
test('en: CAL, SRC and CONTEXT keep their English codes', () => {
|
||||
setLocale('en');
|
||||
assert.match(summary('upstream'), /CAL RAW PRIOR · SRC UPSTREAM/);
|
||||
assert.match(summary(null), /SRC IMAGE · STREET CONTEXT/);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
});
|
||||
|
||||
test('fr: CAL, SRC and CONTEXT values are translated', () => {
|
||||
setLocale('fr');
|
||||
try {
|
||||
assert.match(
|
||||
summary('upstream'),
|
||||
/CAL ESTIM\. INITIALE · SRC AMONT · CONTEXTE RUE/,
|
||||
);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { isolate, t, translateKnown } from '../../i18n/index.js';
|
||||
import { isolate, msg, t } from '../../i18n/index.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
@@ -8,8 +8,45 @@ import {
|
||||
isDatasheetFov,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
/** The translated name of a camera view band (street, city, regional, global). */
|
||||
function viewBandLabel(band) {
|
||||
// The summary line names the CAL badge, the source kind and the view band in
|
||||
// the page language; the codes underneath stay English.
|
||||
const CAL_BADGE_LABELS = Object.freeze({
|
||||
calibrated: msg('CALIBRATED'),
|
||||
curated: msg('CURATED'),
|
||||
'raw-prior': msg('RAW PRIOR'),
|
||||
});
|
||||
|
||||
// The same source-kind words as the CCTV panel (src/ui/cctvFrames.js).
|
||||
const SOURCE_KIND_LABELS = Object.freeze({
|
||||
snapshot: msg('SNAPSHOT'),
|
||||
live: msg('LIVE'),
|
||||
fallback: msg('FALLBACK'),
|
||||
upstream: msg('UPSTREAM'),
|
||||
synthetic: msg('SYNTHETIC'),
|
||||
configured: msg('CONFIGURED'),
|
||||
seed: msg('SEED'),
|
||||
image: msg('IMAGE'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
/** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */
|
||||
export function summaryCalLabel(badge) {
|
||||
const key = String(badge || '');
|
||||
return CAL_BADGE_LABELS[key]
|
||||
? t(CAL_BADGE_LABELS[key])
|
||||
: key.replace('-', ' ').toUpperCase();
|
||||
}
|
||||
|
||||
/** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */
|
||||
export function summarySourceLabel(kind) {
|
||||
const key = String(kind || 'image').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** View band id ('street', 'city'…) as the HUD names it. */
|
||||
export function summaryViewBandLabel(band) {
|
||||
switch (band) {
|
||||
case 'street':
|
||||
return t('STREET', { context: 'view band' });
|
||||
@@ -20,7 +57,7 @@ function viewBandLabel(band) {
|
||||
case 'global':
|
||||
return t('GLOBAL', { context: 'view band' });
|
||||
default:
|
||||
return String(band).toUpperCase();
|
||||
return String(band || '').toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,17 +127,13 @@ export function createPresentation({
|
||||
: t('ISOLATED VIEW'),
|
||||
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
|
||||
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
|
||||
t('CAL {value}', {
|
||||
value: translateKnown(calBadge.replace('-', ' ').toUpperCase()),
|
||||
}),
|
||||
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
|
||||
t('SRC {value}', {
|
||||
value: translateKnown(
|
||||
String(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
).toUpperCase(),
|
||||
value: summarySourceLabel(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
),
|
||||
}),
|
||||
t('{value} CONTEXT', { value: viewBandLabel(viewBand) }),
|
||||
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
|
||||
// Identified hardware: append the model's datasheet specs + attribution.
|
||||
active.camera.spec
|
||||
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The CCTV summary line names the CAL badge, the source kind and the view
|
||||
// band in the page language; unknown codes stay as uppercase codes.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
summaryCalLabel,
|
||||
summarySourceLabel,
|
||||
summaryViewBandLabel,
|
||||
} from './presentation.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
test('English keeps the summary codes', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'RAW PRIOR');
|
||||
assert.equal(summarySourceLabel('image'), 'IMAGE');
|
||||
assert.equal(summarySourceLabel('hls'), 'HLS');
|
||||
assert.equal(summaryViewBandLabel('street'), 'STREET');
|
||||
assert.equal(summaryViewBandLabel('none'), 'NONE');
|
||||
});
|
||||
|
||||
test('Portuguese summary values are translated', () => {
|
||||
inLocale('pt', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'ESTIMATIVA BRUTA');
|
||||
assert.equal(summaryCalLabel('calibrated'), 'CALIBRADA');
|
||||
assert.equal(summarySourceLabel('IMAGE'), 'IMAGEM');
|
||||
assert.equal(summarySourceLabel('mjpeg'), 'MJPEG');
|
||||
assert.equal(summaryViewBandLabel('street'), 'RUA');
|
||||
assert.equal(summaryViewBandLabel('city'), 'CIDADE');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
// Shared with the AI tools, so no i18n import: these errors stay English here
|
||||
// and the cyclones layer translates them where it shows them (translateKnown).
|
||||
const msg = (text) => text;
|
||||
|
||||
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
||||
const malformed = () => new Error('Malformed cyclone snapshot');
|
||||
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
|
||||
const text = (value, max = 160) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
@@ -215,7 +219,7 @@ export function createCycloneSource({
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Cyclone request timed out')),
|
||||
() => controller.abort(new Error(msg('Cyclone request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
t,
|
||||
msg,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
formatQuantity,
|
||||
quantitySeparator,
|
||||
unitLabel,
|
||||
} from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -125,7 +135,9 @@ export function routeDurationText(seconds) {
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
if (!rest) return hours;
|
||||
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
|
||||
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
@@ -394,6 +406,16 @@ export function normalizeDirectionsParams(params = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A travel-mode word inside a sentence: lower case, except in German, where
|
||||
* the words are nouns and keep their capital ("Auto", "Fahrrad").
|
||||
* @param {string} word Translated mode word.
|
||||
* @returns {string}
|
||||
*/
|
||||
function modeWordInSentence(word) {
|
||||
return getLocale() === 'de' ? word : word.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
|
||||
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
|
||||
@@ -409,7 +431,7 @@ export function directionsRowControls(state) {
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
modeLower: modeWordInSentence(t(spec.word)),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,7 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { state } = vesselState;
|
||||
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
const { ensureGeoidReady } = services.geoid;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
@@ -125,6 +125,7 @@ export function createLifecycle({
|
||||
state.interactionHandlerFactory = null;
|
||||
state.interactionKeyTarget = null;
|
||||
state.preRenderRemover = null;
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
state.lastVisibilityUpdate = 0;
|
||||
state.lastFocusUpdate = 0;
|
||||
state.activeFocusCount = 0;
|
||||
@@ -164,7 +165,6 @@ export function createLifecycle({
|
||||
const wasEnabled = state.feed.enabled;
|
||||
state.feed.enabled = true;
|
||||
if (!wasEnabled) beginAisSession();
|
||||
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
|
||||
const activeViewer = viewer || state.viewer;
|
||||
components.rendering.ensureCollections(activeViewer);
|
||||
components.selection.installInteraction(activeViewer);
|
||||
@@ -208,6 +208,7 @@ export function createLifecycle({
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
components.rendering.setVisible(false);
|
||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||
@@ -227,6 +228,7 @@ export function createLifecycle({
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
if (state.feed.abort) state.feed.abort.abort();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
components.selection.clearVesselInspection();
|
||||
|
||||
@@ -29,7 +29,11 @@ function services() {
|
||||
forgetSpriteFocus: noop,
|
||||
},
|
||||
worldFocus: {},
|
||||
render: { releaseContinuousRender: noop },
|
||||
render: {
|
||||
holdContinuousRender: noop,
|
||||
releaseContinuousRender: noop,
|
||||
governorRequestRender: noop,
|
||||
},
|
||||
};
|
||||
}
|
||||
function setup(source, options = {}) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVesselLayer } from './index.js';
|
||||
import { VISIBILITY_UPDATE_MS } from './policy.js';
|
||||
|
||||
// Vessels move only when a poll lands, so the layer must let the scene idle:
|
||||
// it asks for frames for forced passes and one trailing frame after the camera
|
||||
// stops, and never schedules a frame from a pass the camera did not cause.
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function setup() {
|
||||
let clock = 10_000;
|
||||
const requests = [];
|
||||
const holds = new Set();
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const layer = createVesselLayer({
|
||||
source: { getSnapshot: async () => ({ records: [] }) },
|
||||
services: {
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer: noop,
|
||||
registerEntityContext: () => null,
|
||||
},
|
||||
trails: {
|
||||
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
|
||||
},
|
||||
labels: {},
|
||||
picking: { unregisterPickOwner: noop },
|
||||
overlay: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
geoid: {},
|
||||
sprites: {},
|
||||
focus: {
|
||||
focusNowMs: () => clock,
|
||||
getFocusTarget: () => null,
|
||||
focusPassIsNeeded: () => false,
|
||||
forgetSpriteFocus: noop,
|
||||
},
|
||||
worldFocus: {},
|
||||
render: {
|
||||
holdContinuousRender: (id) => holds.add(id),
|
||||
releaseContinuousRender: (id) => holds.delete(id),
|
||||
governorRequestRender: (reason) => requests.push(reason),
|
||||
},
|
||||
},
|
||||
});
|
||||
const camera = {
|
||||
positionWC: { x: 0, y: 0, z: 7_000_000 },
|
||||
heading: 0,
|
||||
pitch: -1.2,
|
||||
roll: 0,
|
||||
};
|
||||
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
|
||||
layer.testing._setAisRuntimeForTest({
|
||||
now: () => clock,
|
||||
setTimeout(fn, ms) {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, { fn, at: clock + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
const advance = (ms) => {
|
||||
clock += ms;
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.at > clock) continue;
|
||||
timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
};
|
||||
const frame = () => layer.testing._runVisibilityPassForTest();
|
||||
return { layer, camera, requests, holds, timers, advance, frame };
|
||||
}
|
||||
|
||||
test('a parked camera schedules no frames after the cadence pass', () => {
|
||||
const { requests, holds, timers, advance, frame } = setup();
|
||||
frame();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
advance(100);
|
||||
frame();
|
||||
}
|
||||
assert.deepEqual(requests, []);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(holds.has('ais-vessels'), false);
|
||||
});
|
||||
|
||||
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
|
||||
const { layer, camera, requests, timers, advance, frame } = setup();
|
||||
frame();
|
||||
advance(100);
|
||||
camera.heading = 0.5;
|
||||
frame();
|
||||
advance(100);
|
||||
frame();
|
||||
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
|
||||
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
|
||||
|
||||
advance(VISIBILITY_UPDATE_MS);
|
||||
assert.deepEqual(requests, ['ais-vessels-visibility']);
|
||||
// The trailing frame runs the regular pass on the pose the camera stopped
|
||||
// on. Its own frames must not schedule another one.
|
||||
frame();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
advance(100);
|
||||
frame();
|
||||
}
|
||||
assert.deepEqual(requests, ['ais-vessels-visibility']);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('a forced pass requests its own frame', () => {
|
||||
const { layer, requests } = setup();
|
||||
layer.testing._runVisibilityPassForTest(true);
|
||||
assert.deepEqual(requests, ['ais-vessels-update']);
|
||||
});
|
||||
|
||||
test('destroy cancels a pending trailing frame', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
});
|
||||
const { layer, camera, requests, timers, advance, frame } = setup();
|
||||
frame();
|
||||
advance(100);
|
||||
camera.heading = 0.5;
|
||||
frame();
|
||||
assert.equal(timers.size, 1);
|
||||
layer.destroy();
|
||||
assert.equal(timers.size, 0);
|
||||
advance(VISIBILITY_UPDATE_MS);
|
||||
assert.deepEqual(requests, []);
|
||||
});
|
||||
@@ -75,7 +75,13 @@ export function createRendering({
|
||||
focusPassIsNeeded,
|
||||
advanceSpriteFocus,
|
||||
focusAlphaNeedsWrite,
|
||||
spriteFocusAtRest,
|
||||
} = services.focus;
|
||||
const {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} = services.render;
|
||||
|
||||
function renderRowLimit() {
|
||||
const configured = Number(options.maxRows);
|
||||
@@ -196,6 +202,14 @@ export function createRendering({
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Vessels move only when a poll lands, so the layer does not hold the
|
||||
// continuous render loop. Its passes run in preRender, which idle mode fires
|
||||
// only for rendered frames: a forced pass (new data, selection) requests its
|
||||
// frame, a frame that moved the camera but fell inside the 800 ms cadence
|
||||
// leaves a trailing frame so the view the camera stopped on is always
|
||||
// evaluated, and the continuous hold is kept only while focus emphasis is
|
||||
// animating. The trailing frame is keyed to camera motion alone: the pass's
|
||||
// own label publication requests a frame, and scheduling on that would loop.
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
@@ -203,8 +217,27 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleTrailingVisibilityPass(now) {
|
||||
if (state.trailingVisibilityTimer) return;
|
||||
const waitMs = Math.max(
|
||||
0,
|
||||
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
|
||||
);
|
||||
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
|
||||
state.trailingVisibilityTimer = null;
|
||||
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
|
||||
}, waitMs);
|
||||
}
|
||||
|
||||
function cancelTrailingVisibilityPass() {
|
||||
if (!state.trailingVisibilityTimer) return;
|
||||
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
|
||||
state.trailingVisibilityTimer = null;
|
||||
}
|
||||
|
||||
function updateVisibility(force = false) {
|
||||
if (!state.feed.enabled) return;
|
||||
if (force) governorRequestRender('ais-vessels-update');
|
||||
const now = focusNowMs(performance.now());
|
||||
const focusTarget = getFocusTarget();
|
||||
const regularPass =
|
||||
@@ -212,13 +245,30 @@ export function createRendering({
|
||||
const focusPass =
|
||||
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
|
||||
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
||||
if (
|
||||
!regularPass &&
|
||||
state.viewer?.camera &&
|
||||
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
|
||||
)
|
||||
scheduleTrailingVisibilityPass(now);
|
||||
if (!regularPass && !focusPass) return;
|
||||
if (regularPass) state.lastVisibilityUpdate = now;
|
||||
if (focusPass) state.lastFocusUpdate = now;
|
||||
if (!state.records.all.length) {
|
||||
// No records — flush any lingering card entries (vanished-feed case).
|
||||
if (regularPass) updateClusteredLabels([]);
|
||||
if (focusPass) state.activeFocusCount = 0;
|
||||
// Still record the pose this pass evaluated, or every later frame that
|
||||
// falls inside the cadence would schedule another trailing frame.
|
||||
if (regularPass) {
|
||||
if (state.viewer?.camera)
|
||||
vesselState._lastCamPoseSig = cameraPoseSignature(
|
||||
state.viewer.camera,
|
||||
);
|
||||
updateClusteredLabels([]);
|
||||
}
|
||||
if (focusPass) {
|
||||
state.activeFocusCount = 0;
|
||||
releaseContinuousRender('ais-vessels');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -272,6 +322,9 @@ export function createRendering({
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
});
|
||||
state.activeFocusCount = result.activeCount;
|
||||
if (result.activeCount > 0 || result.transitioning)
|
||||
holdContinuousRender('ais-vessels');
|
||||
else releaseContinuousRender('ais-vessels');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +355,16 @@ export function createRendering({
|
||||
const bb = visual?.billboard;
|
||||
const position = bb?.position || visual?.position;
|
||||
if (!bb || !position) continue;
|
||||
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
|
||||
// no pending focus state and full alpha would advance to exactly what it
|
||||
// already is, so skip it instead of paying its distance and state work
|
||||
// every 80 ms while anything is tracked.
|
||||
if (
|
||||
bb.show === false &&
|
||||
spriteFocusAtRest(bb) &&
|
||||
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
|
||||
)
|
||||
continue;
|
||||
const focus = advanceSpriteFocus(bb, {
|
||||
// Hidden/far-side sprites still finish any pending release so the active
|
||||
// count remains truthful and they cannot reappear with stale dim alpha.
|
||||
@@ -510,6 +573,7 @@ export function createRendering({
|
||||
shipIcon,
|
||||
installRuntime,
|
||||
updateVisibility,
|
||||
cancelTrailingVisibilityPass,
|
||||
applyVesselFocusDeemphasis,
|
||||
makeOccluder,
|
||||
isVisible,
|
||||
|
||||
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
|
||||
/** Test-only key target paired with interactionHandlerFactory. */
|
||||
interactionKeyTarget: null,
|
||||
preRenderRemover: null,
|
||||
/** Pending timer that renders one frame after a cadence-skipped pass. */
|
||||
trailingVisibilityTimer: null,
|
||||
lastVisibilityUpdate: 0,
|
||||
lastFocusUpdate: 0,
|
||||
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
|
||||
|
||||
@@ -151,6 +151,18 @@ export function createTesting({
|
||||
};
|
||||
}
|
||||
|
||||
/** Run one preRender visibility/focus pass. Test-only seam. */
|
||||
|
||||
function _runVisibilityPassForTest(force = false) {
|
||||
components.rendering.updateVisibility(force);
|
||||
}
|
||||
|
||||
/** True while a trailing visibility frame is scheduled. Test-only seam. */
|
||||
|
||||
function _hasTrailingVisibilityPassForTest() {
|
||||
return state.trailingVisibilityTimer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lifecycle ownership state without exposing the mutable state object.
|
||||
* Test-only seam.
|
||||
@@ -176,5 +188,7 @@ export function createTesting({
|
||||
_setAisRuntimeForTest,
|
||||
_getVesselFeedStateForTest,
|
||||
_getVesselStateForTest,
|
||||
_runVisibilityPassForTest,
|
||||
_hasTrailingVisibilityPassForTest,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user