fix(cockpit): an application subject's eye eases off the floor instead of jumping: its anchor is no longer clamped to the floor, and the eye's clearance above it changes at most 1.5 m/s (easeCockpitLift)

This commit is contained in:
Denis Groshev committed 2026-11-12 10:50:27 +01:00
1 parent f75179eea0
commit 01a74fceec
4 files changed
+225 -47

No files matched your search

+151 -44
View File
@@ -10,6 +10,8 @@ import {
cockpitGroundSafeHeight,
cockpitSurfaceWaitExpired,
cockpitUiUpdateDue,
COCKPIT_SUBJECT_LIFT_MPS,
easeCockpitLift,
compassDivisions,
compassMarkerSlot,
scrollingCompassDivisions,
@@ -45,7 +47,11 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', ()
const speedMps = 200;
const dtSec = 0.1;
const forwardStepM = speedMps * dtSec;
const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec);
const backwardCorrectionM = cockpitAnchorCorrectionStep(
1000,
speedMps,
dtSec,
);
assert.ok(backwardCorrectionM > 0);
assert.ok(backwardCorrectionM < forwardStepM);
});
@@ -81,10 +87,7 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in
});
test('cockpit Context scope preserves zero and replaces missing values intentionally', () => {
assert.equal(
formatCockpitContextScope('', 0),
'— · 0 KM AIR/SEA WINDOW',
);
assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW');
assert.equal(
formatCockpitContextScope(undefined, Number.NaN, {}),
'— · — KM AIR/SEA WINDOW',
@@ -221,26 +224,29 @@ test('relative bearing stays on the shortest signed arc', () => {
});
test('HUD rail layout centers between intersecting upper and lower obstacles', () => {
assert.deepEqual(resolveHudRailLayout({
viewportHeight: 1000,
panelHeight: 200,
laneLeft: 50,
laneRight: 410,
baseTop: 280,
baseBottom: 940,
gap: 10,
obstacles: [
{ left: 40, right: 240, top: 320, bottom: 460 },
{ left: 300, right: 1000, top: 780, bottom: 930 },
{ left: 900, right: 1100, top: 300, bottom: 700 },
],
}), {
top: 520,
maxHeight: 300,
safeTop: 470,
safeBottom: 770,
constrained: false,
});
assert.deepEqual(
resolveHudRailLayout({
viewportHeight: 1000,
panelHeight: 200,
laneLeft: 50,
laneRight: 410,
baseTop: 280,
baseBottom: 940,
gap: 10,
obstacles: [
{ left: 40, right: 240, top: 320, bottom: 460 },
{ left: 300, right: 1000, top: 780, bottom: 930 },
{ left: 900, right: 1100, top: 300, bottom: 700 },
],
}),
{
top: 520,
maxHeight: 300,
safeTop: 470,
safeBottom: 770,
constrained: false,
},
);
});
test('HUD rail layout constrains an oversized panel to the safe corridor', () => {
@@ -294,7 +300,10 @@ function contextSnapshot(subject, extra = {}) {
}
test('Contact panel hides only when there is no snapshot at all', () => {
const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO });
const readout = resolveCockpitContextReadout({
snapshot: null,
info: TRACKED_INFO,
});
assert.equal(readout.visible, false);
assert.equal(readout.mode, 'standby');
assert.equal(
@@ -310,14 +319,22 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
// camera: "click next... whole left panel disappears").
for (const subject of [
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
{ layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' },
{
layerId: 'military-installations',
id: 'fort-hood',
label: 'FORT CAVAZOS',
},
{ layerId: 'military', id: 'ae01ce', label: 'RCH451' },
]) {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot(subject),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`);
assert.equal(
readout.visible,
true,
`${subject.layerId} subject must keep the panel up`,
);
assert.equal(readout.mode, 'foreign');
assert.equal(readout.subjectMatchesTracked, false);
}
@@ -325,7 +342,11 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }),
snapshot: contextSnapshot({
layerId: 'ais-live-vessels',
id: '353136000',
label: 'MAERSK DETROIT',
}),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true);
@@ -334,12 +355,20 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l
// frame while every other row value is measured from the subject; rendering
// both live would present one mixed-frame reading as a single measurement.
assert.equal(readout.aircraftRelative, false);
assert.equal(readout.contactLost, false, 'subject-frame values still refresh');
assert.equal(
readout.contactLost,
false,
'subject-frame values still refresh',
);
});
test('the tracked aircraft as subject keeps every field in its own frame', () => {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }),
snapshot: contextSnapshot({
layerId: 'flights',
id: 'aaa077',
label: 'SWA1234',
}),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true);
@@ -358,7 +387,11 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
snapshot: contextSnapshot(subject, { subjectPresent: false }),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true, 'a lost contact must not take the panel down');
assert.equal(
readout.visible,
true,
'a lost contact must not take the panel down',
);
assert.equal(readout.mode, 'lost');
assert.equal(readout.contactLost, true, 'last-known values stay on screen');
}
@@ -366,7 +399,10 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
test('an explicitly present subject is never reported lost', () => {
const present = resolveCockpitContextReadout({
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }),
snapshot: contextSnapshot(
{ layerId: 'flights', id: 'aaa077' },
{ subjectPresent: true },
),
info: TRACKED_INFO,
});
assert.equal(present.contactLost, false);
@@ -385,20 +421,36 @@ test('the tracked flight layer is resolved by normalized tracked identity', () =
// Both layers describe a tracked aircraft during a cross-layer handoff;
// civilian-first precedence would hand the cockpit the wrong aircraft.
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId,
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'military:ae01ce',
}).layerId,
'military',
);
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24,
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'military:ae01ce',
}).icao24,
'ae01ce',
);
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId,
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'flights:aaa077',
}).layerId,
'flights',
);
// Case-insensitive: layers stamp lowercase hex, callers may not.
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId,
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'MILITARY:AE01CE',
}).layerId,
'military',
);
});
@@ -408,30 +460,85 @@ test('tracked-identity resolution falls back to layer precedence', () => {
const military = { icao24: 'ae01ce' };
// No stamped identity (or one that matches neither layer) keeps the historic
// civilian-first order so tracking paths without gevTrackedId still work.
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
assert.equal(
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
resolveTrackedAircraftInfo({ civilian, military }).layerId,
'flights',
);
assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military');
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'satellites:25544',
}).layerId,
'flights',
);
assert.equal(
resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId,
'military',
);
assert.equal(
resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }),
null,
);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
formatCockpitEmergency({
label: 'General emergency',
squawk: '7700',
source: 'squawk',
severity: 'emergency',
}),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
formatCockpitEmergency({
label: 'Downed aircraft',
squawk: '1200',
source: 'ads-b',
severity: 'emergency',
}),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
formatCockpitEmergency({
label: 'Minimum fuel',
squawk: null,
source: 'ads-b',
severity: 'priority',
}),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
assert.equal(formatCockpitEmergency({ label: '' }), null);
});
test('an application subject’s ground lift eases its clearance, never the floor', () => {
// On entry the lift asked for applies at once.
assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5);
// A floor cell that rises by a metre is met at the lift rate...
let lift = 0;
const frames = [];
for (let i = 0; i < 60; i += 1) {
lift = easeCockpitLift(lift, 1, 0, 0.016);
frames.push(lift);
}
assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9);
for (let i = 1; i < frames.length; i += 1)
assert.ok(
frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9,
);
assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second');
// ...and given back the same way.
assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9);
// What keeps the eye out of the floor itself is never eased.
assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200);
assert.equal(
easeCockpitLift(0.2, -3, -3, 0.016),
0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016,
);
assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0);
});