fix(cockpit): an application subject keeps its rates on a slow update interval, its ground lift eases out, and its HUD shows a speed and a progress name only when given

An application subject's camera step is now capped at its own update
interval plus 0.1 s rather than at 0.1 s, so at 500 ms its heading slew,
ground lift and anchor correction run at their configured rates; a long
stall still moves it no further than one late update. The built-in
views keep their 0.1 s cap.

When groundClamp turns false the lift the floor gave goes at the rate
it came, not at once. A HUD view with no speed has no speed readout,
and a progress line with no summary is hidden from assistive
technology instead of being an unnamed image.
This commit is contained in:
Denis Groshev committed 2026-11-12 17:00:12 +01:00
1 parent feaf59776c
commit c24352ff5e
7 files changed
+215 -19

No files matched your search

+32 -2
View File
@@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) {
* then uses this amount to converge on the layer's delayed display position.
* Capping correction below forward speed prevents a late feed fix from
* becoming a first-person surge or reversal while still removing drift.
* @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an
* application subject's own (cockpitSubjectStepSec).
*/
export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
export function cockpitAnchorCorrectionStep(
distanceM,
speedMps,
dtSec,
maxDtSec = 0.1,
) {
if (
!Number.isFinite(distanceM) ||
distanceM <= 0 ||
@@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) {
dtSec <= 0
)
return 0;
const dt = Math.min(0.1, dtSec);
const dt = Math.min(
Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1,
dtSec,
);
const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0;
const eased = distanceM * (1 - Math.exp(-1.25 * dt));
const correctionRateMps = Math.max(0.75, speed * 0.22);
return Math.min(distanceM, eased, correctionRateMps * dt);
}
/**
* The seconds an application subject's camera update advances its heading
* slew, ground lift and anchor by: the time since the last update, at most
* its update interval plus 0.1 s. A subject updated every 500 ms then turns
* and eases at its configured rates, while a long stall (a hidden tab) still
* moves it no further than one late update would.
* @param {number} elapsedMs Time since the last camera update.
* @param {number} intervalMs The subject's update interval (0: every frame).
* @returns {number}
*/
export function cockpitSubjectStepSec(elapsedMs, intervalMs) {
const elapsedSec = Number.isFinite(elapsedMs)
? Math.max(0, elapsedMs) / 1000
: 0;
const intervalSec = Number.isFinite(intervalMs)
? Math.max(0, intervalMs) / 1000
: 0;
return Math.min(intervalSec + 0.1, elapsedSec);
}
/** Return whether a throttled cockpit presentation update is due. */
export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) {
if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0)
+23
View File
@@ -8,6 +8,7 @@ import {
cockpitAnchorCorrectionStep,
cockpitAltitudeDisplayFt,
cockpitGroundSafeHeight,
cockpitSubjectStepSec,
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
COCKPIT_SUBJECT_LIFT_MPS,
@@ -58,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', ()
assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0);
});
test('an application subject on a slow cadence is corrected for its whole step', () => {
// The built-in views' step stays capped at 0.1 s...
const capped = cockpitAnchorCorrectionStep(100, 0, 0.5);
assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1));
// ...while a subject updated every 500 ms is corrected for all of it.
const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5);
assert.ok(Math.abs(whole / capped - 5) < 1e-9);
assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped);
});
test('an application subject steps by its interval, never by a long stall', () => {
assert.equal(cockpitSubjectStepSec(16, 0), 0.016);
assert.equal(cockpitSubjectStepSec(500, 500), 0.5);
assert.equal(cockpitSubjectStepSec(520, 500), 0.52);
// A hidden tab's minutes count as one late update.
assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6);
assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1);
assert.equal(cockpitSubjectStepSec(-5, 500), 0);
assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0);
assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1);
});
test('cockpit camera clearance never falls below the rendered ground floor', () => {
assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69);
assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200);
+20 -13
View File
@@ -4,6 +4,7 @@ import {
cockpitAnchorCorrectionStep,
cockpitGroundSafeHeight,
easeCockpitLift,
cockpitSubjectStepSec,
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
slewHeading,
@@ -308,7 +309,8 @@ function updateVessel(nowMs) {
* stand the eye `eye.upM` above `heightM` (the shared floor where the frame
* gives none) and slew toward its heading. A frame that is null, malformed or
* for another subject ends the view, as an aircraft tracked meanwhile does.
* The update runs on the provider's `cameraIntervalMs` (0: every frame).
* The update runs on the provider's `cameraIntervalMs` (0: every frame), and
* its rates advance by the time that interval spans (cockpitSubjectStepSec).
*/
function updateSubject(nowMs) {
const provider = this.subjectProvider;
@@ -316,14 +318,8 @@ function updateSubject(nowMs) {
this.exit({ restoreTracking: false });
return;
}
if (
!cockpitUiUpdateDue(
nowMs,
this.lastCameraUpdateMs,
provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS,
)
)
return;
const intervalMs = provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS;
if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, intervalMs)) return;
this.lastCameraUpdateMs = nowMs;
const frame = cockpitSubjectFrame(
this.callSubject(provider, 'read', [nowMs, this.subjectContext()], null),
@@ -385,7 +381,7 @@ function updateSubject(nowMs) {
Cesium.Ellipsoid.WGS84,
this.scratchTarget,
);
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
const dtSec = cockpitSubjectStepSec(nowMs - this.lastFrameMs, intervalMs);
this.lastFrameMs = nowMs;
if (frame.headingDeg !== null) {
const rate = provider.headingSlewDps ?? COCKPIT_HEADING_SLEW_DPS;
@@ -429,7 +425,7 @@ function updateSubject(nowMs) {
* the eye against the rendered surface, and look along the slewed heading.
* A subject with `anchor: 'exact'` keeps the anchor on its target (its layer
* already moves it smoothly), and one with `groundClamp: false` (inside a
* structure, say) is never pushed up to the floor.
* structure, say) is never pushed up to the floor: a lift it had eases out.
* @param {Cesium.Cartesian3} target Authoritative subject position.
* @param {object} info Subject info; `motionCourse` (when present) is the
* direction the subject moves, otherwise it moves where it faces.
@@ -509,10 +505,13 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
const correctionDistanceM = Cesium.Cartesian3.magnitude(
this.scratchCorrection,
);
// An application subject's step is already bounded by its own update
// interval (cockpitSubjectStepSec); the built-in views keep the 0.1 s.
const correctionStepM = cockpitAnchorCorrectionStep(
correctionDistanceM,
speedMps,
dtSec,
info.generic === true ? dtSec : undefined,
);
if (correctionStepM > 0 && correctionDistanceM > 0) {
Cesium.Cartesian3.multiplyByScalar(
@@ -756,8 +755,16 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
);
}
} else if (info.generic === true) {
// Inside a structure there is no clearance to give back.
this.subjectLiftM = 0;
// Inside a structure the floor holds the eye up no more: the lift it gave
// goes at the rate it came (easeCockpitLift), never at once.
this.subjectLiftM = easeCockpitLift(this.subjectLiftM, 0, 0, dtSec);
if (cameraCartographic && this.subjectLiftM > 0) {
cameraCartographic.height += this.subjectLiftM;
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
cameraCartographic,
this.scratchCamera,
);
}
}
this.viewer.camera.setView({
+130 -1
View File
@@ -16,6 +16,7 @@ import ContextView from './views/ContextView.vue';
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js';
import { COCKPIT_SUBJECT_LIFT_MPS } from '../cockpitMath.js';
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
const LAT = 47;
@@ -408,6 +409,47 @@ test('entry holds the subject, takes the camera and shows the generic HUD', asyn
assert.ok(shots.length >= 1 || cockpit.lastSubjectFrame);
});
test('a HUD view with no speed shows no speed readout; one with no value shows it unknown', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.hud = { ...HUD, speed: undefined };
const { $, cockpit } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
await settle();
assert.ok($('.cockpit-speed') === null, 'no speed readout');
assert.ok($('#cockpit-speed-value') === null, 'no speed value');
assert.ok($('#cockpit-heading-value') !== null, 'the heading stays');
ride.subject.hud = { ...HUD, speed: { value: null, unit: 'km/h' } };
clock.advance(1000);
cockpit.update();
await settle();
assert.match($('.cockpit-speed').textContent, /---/);
assert.match($('.cockpit-speed').textContent, /km\/h/);
});
test('a progress line is an image named by its summary, else hidden from assistive technology', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
ride.subject.hud = { ...HUD, progress: { ...HUD.progress, summary: '' } };
const { $, cockpit } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
await settle();
let progress = $('.cockpit-progress');
assert.equal(progress.hasAttribute('role'), false);
assert.equal(progress.hasAttribute('aria-label'), false);
assert.equal(progress.getAttribute('aria-hidden'), 'true');
ride.subject.hud = HUD;
clock.advance(1000);
cockpit.update();
await settle();
progress = $('.cockpit-progress');
assert.equal(progress.getAttribute('role'), 'img');
assert.equal(progress.getAttribute('aria-label'), HUD.progress.summary);
assert.equal(progress.hasAttribute('aria-hidden'), false);
});
test('the eye stands its height above the frame, and the ground clamp can be lifted', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
@@ -424,7 +466,8 @@ test('the eye stands its height above the frame, and the ground clamp can be lif
assert.equal(shots.length, before + 1);
// Inside a structure the frame stands below the rendered floor: clamped,
// the eye is pushed out of the floor at once and its clearance eases in;
// with groundClamp false it stays.
// a view entered with groundClamp false stays inside (a clamp lifted
// mid-ride lets the eye down gradually, below).
setFloor(FLOOR_M + 200);
clock.advance(16);
cockpit.update();
@@ -437,6 +480,9 @@ test('the eye stands its height above the frame, and the ground clamp can be lif
}
assert.ok(shotHeight(shots) >= FLOOR_M + 200 + 1.5 - 0.01, 'clamped');
ride.subject.frame = { ...ride.subject.frame, groundClamp: false };
cockpit.exit();
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
clock.advance(16);
cockpit.update();
assert.ok(
@@ -451,6 +497,89 @@ test('the eye stands its height above the frame, and the ground clamp can be lif
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
});
test('a ground clamp lifted mid-ride lets the eye down at the rate it came up', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
clock.advance(16);
cockpit.update();
// The floor rises to the eye: its clearance eases in.
setFloor(FLOOR_M + 3.5);
for (let i = 0; i < 80; i += 1) {
clock.advance(16);
cockpit.update();
}
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 5)) < 0.01, 'clamped');
// Into a structure: the lift goes at the same rate, not at once.
ride.subject.frame = { ...ride.subject.frame, groundClamp: false };
clock.advance(100);
cockpit.update();
assert.ok(
Math.abs(
shotHeight(shots) - (FLOOR_M + 5 - COCKPIT_SUBJECT_LIFT_MPS * 0.1),
) < 0.01,
'eased out',
);
for (let i = 0; i < 12; i += 1) {
clock.advance(100);
cockpit.update();
}
assert.ok(
Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01,
'inside the structure',
);
});
test('a subject updated every 500 ms turns, eases and closes on its target at its rates', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider({ cameraIntervalMs: 500, headingSlewDps: 10 });
ride.subject.frame = {
...ride.subject.frame,
anchor: 'inertial',
speedMps: 0,
};
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
clock.advance(500);
cockpit.update();
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
// One 500 ms update: the subject turns, the floor rises to the eye and the
// frame moves 100 m east of the anchor. Each rate advances by the 0.5 s,
// five times the 0.1 s a frame's step is capped at.
ride.subject.frame = {
...ride.subject.frame,
headingDeg: 180,
longitude: LON + 100 / (111_320 * Math.cos(Cesium.Math.toRadians(LAT))),
};
setFloor(FLOOR_M + 3.5);
clock.advance(500);
cockpit.update();
assert.ok(Math.abs(cockpit.heading - 95) < 1e-6, 'heading 10°/s × 0.5 s');
assert.ok(
Math.abs(
shotHeight(shots) - (FLOOR_M + 3.5 + COCKPIT_SUBJECT_LIFT_MPS * 0.5),
) < 0.01,
'lift 1.5 m/s × 0.5 s',
);
const ground = (shot) => {
const at = Cesium.Cartographic.fromCartesian(shot.destination);
return Cesium.Cartesian3.fromRadians(at.longitude, at.latitude, 0);
};
const closedM = Cesium.Cartesian3.distance(
ground(shots.at(-1)),
ground(shots.at(-2)),
);
// A standing subject's anchor closes at the correction's 0.75 m/s.
assert.ok(Math.abs(closedM - 0.75 * 0.5) < 0.01, 'anchor 0.75 m/s × 0.5 s');
// A long stall (a hidden tab) moves it as one late update would.
clock.advance(60_000);
cockpit.update();
assert.ok(Math.abs(cockpit.heading - 101) < 1e-6, 'at most 0.6 s');
});
test('an exact anchor sits on the subject, wherever the frame moves it', async (t) => {
const clock = manualClock(t, 10_000);
const ride = subjectProvider();
+4
View File
@@ -162,6 +162,10 @@
#cockpit-hud[data-generic] .cockpit-speed-tag {
font-weight: 700;
}
/* With no speed readout the heading still holds the middle of the row. */
#cockpit-hud[data-generic] .cockpit-heading-value {
grid-column: 2;
}
#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='warning'],
#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='alert'] {
font-weight: 700;
+2 -1
View File
@@ -266,7 +266,8 @@ const tickStyle = (tick) =>
</div>
<div v-if="!orbital" class="cockpit-compass" :aria-label="generic ? hudLabel : bridge ? t('Current vessel heading') : t('Current aircraft heading')">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<!-- A provider's HUD view without a speed has no speed readout. -->
<div v-if="!generic || hud.speed" class="cockpit-readout cockpit-speed">
<span v-if="!generic" class="cockpit-readout-label">{{ bridge ? t('SPEED OVER GROUND') : t('GROUND SPEED') }}</span>
<span v-else-if="hud.speedTag" class="cockpit-readout-label cockpit-speed-tag" :data-tone="hud.speedTone">{{ hud.speedTag }}</span>
<CockpitRollingValue id="cockpit-speed-value" tag="strong" :reading="hud.speed" placeholder="---" />
+4 -2
View File
@@ -5,7 +5,9 @@ import { cockpitProgressLayout } from '../../../cockpitProgress.js';
// A provider subject's progress line (src/cockpitSubjectProviders.js): a thin
// line along the bottom of the view, its ticks, labels and marker laid out
// at the line's own width (src/cockpitProgress.js). The part before the
// marker is dimmed. Everything it shows is worded by the provider.
// marker is dimmed. Everything it shows is worded by the provider. It is an
// image named by the provider's summary; with no summary it is decoration,
// hidden from assistive technology.
const props = defineProps({
progress: { type: Object, required: true },
@@ -41,7 +43,7 @@ const percent = (x) => `${((x / width.value) * 100).toFixed(3)}%`;
</script>
<template>
<div ref="root" class="cockpit-progress" role="img" :aria-label="progress.summary || undefined">
<div ref="root" class="cockpit-progress" :role="progress.summary ? 'img' : undefined" :aria-label="progress.summary || undefined" :aria-hidden="progress.summary ? undefined : 'true'">
<template v-if="layout">
<div class="cockpit-progress-line" aria-hidden="true">
<span class="cockpit-progress-travelled" :style="{ width: percent(layout.travelledX) }"></span>