mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
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:
1 parent
feaf59776c
commit
c24352ff5e
7 files changed
+215
-19
No files matched your search
+32
-2
@@ -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)
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="---" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user