From c24352ff5e9a9000a0910396ed446933186e0104 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Thu, 12 Nov 2026 16:59:28 +0100 Subject: [PATCH] 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. --- src/cockpitMath.js | 34 +++++- src/cockpitMath.test.mjs | 23 +++++ src/ui/cockpitCamera.js | 33 +++--- src/ui/cockpitSubjectView.test.mjs | 131 +++++++++++++++++++++++- src/ui/styles/cockpitSubject.css | 4 + src/ui/views/CockpitView.vue | 3 +- src/ui/views/panels/CockpitProgress.vue | 6 +- 7 files changed, 215 insertions(+), 19 deletions(-) diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 7c81c2b4c..c304cac75 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -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) diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index b6b6f5849..104190fa8 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -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); diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 24ed7f60d..ed66369b9 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -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({ diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index e20b03917..59ec07dc9 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -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(); diff --git a/src/ui/styles/cockpitSubject.css b/src/ui/styles/cockpitSubject.css index 172d41f6c..37031ee8b 100644 --- a/src/ui/styles/cockpitSubject.css +++ b/src/ui/styles/cockpitSubject.css @@ -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; diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 024165859..0d7fc4e9a 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -266,7 +266,8 @@ const tickStyle = (tick) =>
-
+ +
{{ bridge ? t('SPEED OVER GROUND') : t('GROUND SPEED') }} {{ hud.speedTag }} diff --git a/src/ui/views/panels/CockpitProgress.vue b/src/ui/views/panels/CockpitProgress.vue index 3559491ed..5e57a80cd 100644 --- a/src/ui/views/panels/CockpitProgress.vue +++ b/src/ui/views/panels/CockpitProgress.vue @@ -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)}%`;