mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Keep the ridden aircraft's layer when Contacts ends, and clear its rows from Live Signals
Riding an aircraft now adopts its layer the way a direct selection does, and Contacts gives up a dependency the user adopts, so ending Contacts mid-session (one of its layers off, or Contacts itself) no longer turns off the ridden layer and ends the Cockpit. When Contacts goes off, Live Signals drops the rows built from its snapshot instead of leaving stale distances marked CURRENT, and the feed-off warning is no longer duplicated.
This commit is contained in:
1 parent
459b0e8a71
commit
63fd559496
14 files changed
+441
-16
No files matched your search
@@ -1,5 +1,6 @@
|
||||
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
|
||||
required; turning off the aircraft's own feed ends the view, a missing other
|
||||
required, and ending Contacts mid-session keeps the view and the layer it
|
||||
rides; turning off the aircraft's own feed ends the view, a missing other
|
||||
feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed
|
||||
aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS,
|
||||
#1556).
|
||||
+10
-5
@@ -6021,11 +6021,16 @@ apply to `public/` assets or built-preview serving.
|
||||
tracked: Live Flights for a civilian, Military Flights for a military aircraft
|
||||
(the tracked info's `layerId`; `cockpitEntryAllowed` in
|
||||
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
|
||||
layer, Live AIS Vessels and every other layer may be turned on or off before or
|
||||
during a session. A Context mode change still in flight blocks entry until it
|
||||
settles. Turning off the layer the view rides ends it. The visible Cockpit
|
||||
actions, the `C` shortcut and voice use the same gate; voice entry still opens
|
||||
Contacts first, as its layer-targeted entry navigates through Contacts.
|
||||
layer, Live AIS Vessels and every other layer may be turned on or off before
|
||||
or during a session. A Context mode change still in flight blocks entry until
|
||||
it settles. Turning off the layer the view rides ends it. Riding an aircraft
|
||||
while Contacts is on adopts its layer as the user's own, as a direct selection
|
||||
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
|
||||
its layers going off, restores the pre-Contacts layers but keeps the ridden
|
||||
one and the view; Live Signals then drops the rows Contacts built and shows
|
||||
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
|
||||
the same gate; voice entry still opens Contacts first, as its layer-targeted
|
||||
entry navigates through Contacts.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
|
||||
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
|
||||
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
|
||||
|
||||
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
|
||||
// status its module never emits guards nothing. See the settled-but-fetching
|
||||
// tests below, which use militaryInstallations' genuine first-fetch readout.
|
||||
layerStats = {},
|
||||
subscribe = undefined,
|
||||
} = {}) {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
|
||||
setEnabled,
|
||||
getLayerLifecycleState,
|
||||
layers,
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
|
||||
}
|
||||
});
|
||||
|
||||
test('a dependency the user adopts stays on when Contacts turns off', async () => {
|
||||
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
|
||||
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
|
||||
// coordinator must give it up, or its OFF release tears the layer down —
|
||||
// and the track or the Cockpit with it — before Context restores it.
|
||||
const calls = [];
|
||||
const subscribers = new Set();
|
||||
const runtime = installAwarenessRuntime({
|
||||
isEnabled: () => false,
|
||||
isEffectivelyEnabled: () => false,
|
||||
setEnabled: (layerId, enabled) => {
|
||||
calls.push({ layerId, enabled });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
await nextTurn();
|
||||
for (const listener of subscribers)
|
||||
listener({
|
||||
type: 'visibility',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
adopted: true,
|
||||
adoptedFromSelection: true,
|
||||
});
|
||||
await militaryAwarenessLayer.disable();
|
||||
const released = calls
|
||||
.filter(({ enabled }) => !enabled)
|
||||
.map(({ layerId }) => layerId)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
released,
|
||||
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
|
||||
'the adopted layer is the user\'s now; the rest are still released',
|
||||
);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
}
|
||||
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
|
||||
});
|
||||
|
||||
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
|
||||
let enabled = false;
|
||||
let releaseAircraft;
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
attachDataManager(dataManager) {
|
||||
layerState.dataManager = dataManager;
|
||||
parts.dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
|
||||
@@ -79,6 +79,25 @@ export function createDependencies({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a dependency the user adopts. A direct selection, or the Cockpit
|
||||
* riding one of its aircraft, promotes a layer Contacts turned on into the
|
||||
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
|
||||
* Contacts goes off would tear down that track or Cockpit before the
|
||||
* Context restore could put the layer back.
|
||||
*/
|
||||
function relinquishAdoptedDependency(change) {
|
||||
if (change?.type === 'visibility' && change.adopted && change.enabled)
|
||||
layerState.ownedDependencies.delete(change.layerId);
|
||||
}
|
||||
|
||||
/** Follow one manager's adoptions; null stops following. */
|
||||
function watchAdoptions(dataManager) {
|
||||
layerState.unwatchAdoptions?.();
|
||||
layerState.unwatchAdoptions =
|
||||
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
|
||||
}
|
||||
|
||||
function activateOperationalContext() {
|
||||
const activationId = ++layerState.activationId;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
@@ -113,6 +132,7 @@ export function createDependencies({
|
||||
refreshAfterDeferredDependency,
|
||||
enableDependencies,
|
||||
releaseOwnedDependencies,
|
||||
watchAdoptions,
|
||||
activateOperationalContext,
|
||||
};
|
||||
}
|
||||
@@ -340,6 +340,7 @@ export function createLifecycle({
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
parts.dependencies.watchAdoptions(null);
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export function createState({ services }) {
|
||||
// layers activate only after Contacts is explicitly selected.
|
||||
passive: true,
|
||||
ownedDependencies: new Set(),
|
||||
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||
unwatchAdoptions: null,
|
||||
subject: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
|
||||
@@ -75,6 +75,9 @@ export function updateContext(info, heading) {
|
||||
const readout = resolveCockpitContextReadout({ snapshot, info });
|
||||
const partial = readPartialPicture.call(this);
|
||||
if (!readout.visible) {
|
||||
// Contacts just went off: the rows its snapshot built stop being
|
||||
// measured, so they go before the standby rows replace them.
|
||||
if (this.presence.context) this.dropContextSnapshotSignals?.();
|
||||
this.showContextCard(false);
|
||||
this.contextLayoutStamp = null;
|
||||
this.pushCockpitSignal(
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
updateContext,
|
||||
} from './cockpitContext.js';
|
||||
import { createContextState } from './cockpitContextState.js';
|
||||
import {
|
||||
dropCockpitSignal,
|
||||
dropContextSnapshotSignals,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
updateCockpitSignals,
|
||||
} from './cockpitSignals.js';
|
||||
import { syncWeatherToggle } from './cockpitInstruments.js';
|
||||
import { setContextCollapsed } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
@@ -261,6 +268,62 @@ test('without Contacts, a feed that is off adds a signal until it is back', asyn
|
||||
assert.deepEqual(dropped, ['feed-status']);
|
||||
});
|
||||
|
||||
/** The fixture's owner with the real Live Signals presenters. */
|
||||
function withSignals(owner) {
|
||||
const published = [];
|
||||
Object.assign(owner, {
|
||||
signalItems: [],
|
||||
signalSignatures: new Map(),
|
||||
publishSignals: (rows) => published.push(rows),
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
dropCockpitSignal,
|
||||
dropContextSnapshotSignals,
|
||||
updateCockpitSignals,
|
||||
});
|
||||
return () => published.at(-1).map((row) => row.title);
|
||||
}
|
||||
|
||||
test('Contacts going off clears its contact rows and leaves standby', async (t) => {
|
||||
const { owner } = fixture(t, snapshot());
|
||||
const rows = withSignals(owner);
|
||||
owner.services.isLayerEnabled = () => true;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.ok(
|
||||
owner.signalItems.some((item) => item.key.startsWith('flight:')),
|
||||
'Contacts fills the rows',
|
||||
);
|
||||
assert.ok(owner.signalItems.some((item) => item.key === 'input-status'));
|
||||
// Both feeds stay on; only Contacts goes.
|
||||
owner.snapshot = null;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), ['CONTEXT STANDBY']);
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), ['CONTEXT STANDBY'], 'standby holds still');
|
||||
});
|
||||
|
||||
test('Contacts going off with a feed off keeps the feed row beside standby', async (t) => {
|
||||
const { owner } = fixture(t, snapshot());
|
||||
const rows = withSignals(owner);
|
||||
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
|
||||
updateContext.call(owner, INFO, 0);
|
||||
owner.snapshot = null;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), [
|
||||
'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
'CONTEXT STANDBY',
|
||||
]);
|
||||
});
|
||||
|
||||
test('standby without Contacts keeps the rows the session already shows', async (t) => {
|
||||
const { owner } = fixture(t, null);
|
||||
const rows = withSignals(owner);
|
||||
owner.services.isLayerEnabled = () => true;
|
||||
owner.pushCockpitSignal('track', 'track', 'TRACK ACQUIRED', 'UAL123');
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), ['CONTEXT STANDBY', 'TRACK ACQUIRED']);
|
||||
});
|
||||
|
||||
test('the weather toggle renders its state and the action it offers', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
syncWeatherToggle.call(owner, true);
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from './cockpitBriefing.js';
|
||||
import {
|
||||
dropCockpitSignal,
|
||||
dropContextSnapshotSignals,
|
||||
handleSignalClick,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
@@ -82,6 +83,7 @@ export class CockpitViewController {
|
||||
isUtilityDisclosureOpen = null,
|
||||
onEntered = null,
|
||||
onExited = null,
|
||||
onRide = null,
|
||||
restoreTrackingFrame = null,
|
||||
publish = {},
|
||||
} = {},
|
||||
@@ -121,6 +123,7 @@ export class CockpitViewController {
|
||||
this.onCameraTakeover = onCameraTakeover;
|
||||
this.onEntered = onEntered;
|
||||
this.onExited = onExited;
|
||||
this.onRide = onRide;
|
||||
this.restoreTrackingFrame =
|
||||
typeof restoreTrackingFrame === 'function'
|
||||
? restoreTrackingFrame
|
||||
@@ -394,6 +397,9 @@ export class CockpitViewController {
|
||||
dropCockpitSignal(key) {
|
||||
return dropCockpitSignal.call(this, key);
|
||||
}
|
||||
dropContextSnapshotSignals() {
|
||||
return dropContextSnapshotSignals.call(this);
|
||||
}
|
||||
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
|
||||
return updateCockpitSignals.call(
|
||||
this,
|
||||
|
||||
@@ -97,6 +97,7 @@ export class CockpitCoordinator {
|
||||
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
|
||||
onEntered: () => this.enterPanels(),
|
||||
onExited: () => this.exitPanels(),
|
||||
onRide: (layerId) => this._adoptRiddenLayer(layerId),
|
||||
restoreTrackingFrame: (entity) => {
|
||||
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
|
||||
':',
|
||||
@@ -127,6 +128,22 @@ export class CockpitCoordinator {
|
||||
_isLayerEnabled(layerId) {
|
||||
return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId);
|
||||
}
|
||||
/**
|
||||
* Make the ridden aircraft's layer the user's own while Contacts is on.
|
||||
* Contacts may have turned that layer on, and it releases the layers it
|
||||
* owns when it goes off; the Cockpit must outlive Contacts, so riding an
|
||||
* aircraft adopts its layer as a direct selection does. Contacts then stops
|
||||
* owning it and the Context restore keeps it (session `userAdded`).
|
||||
* @param {string|null} layerId The ridden aircraft's layer.
|
||||
*/
|
||||
_adoptRiddenLayer(layerId) {
|
||||
if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return;
|
||||
if (!this._isLayerEnabled('military-awareness')) return;
|
||||
this._dataManager?.adoptLayerVisibility?.(layerId, true, {
|
||||
origin: 'user',
|
||||
adoptedFromSelection: true,
|
||||
});
|
||||
}
|
||||
/** Whether either aircraft layer is on, so there is an aircraft to ride. */
|
||||
get _aircraftLayerEnabled() {
|
||||
return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) =>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// src/ui/cockpitEntryGate.test.mjs — first person needs only the layer of
|
||||
// the vehicle it rides: Contacts and the other feeds may be off before or
|
||||
// during a session, turning the ridden layer off ends it, and the voice gate
|
||||
// and state say so without claiming Contacts is required. Over happy-dom.
|
||||
// during a session, turning the ridden layer off ends it, Contacts ending
|
||||
// keeps the ridden layer it had turned on, and the voice gate and state say
|
||||
// so without claiming Contacts is required. Over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue';
|
||||
import { DataLayerManager } from '../data/manager.js';
|
||||
import { createDependencies } from '../layers/awareness/dependencies.js';
|
||||
import { CockpitCoordinator } from './cockpitCoordinator.js';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
import { _handleContextLayerChange } from './contextLayerChanges.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import { useHudStore } from './stores/hud.js';
|
||||
@@ -34,6 +38,8 @@ function fixture(
|
||||
tracked = null,
|
||||
contextMode = null,
|
||||
changing = false,
|
||||
dataManager: suppliedManager = null,
|
||||
readContext = null,
|
||||
} = {},
|
||||
) {
|
||||
document.body.replaceChildren();
|
||||
@@ -45,22 +51,29 @@ function fixture(
|
||||
app.mount(host);
|
||||
const store = useCockpitStore(pinia);
|
||||
const layers = new Set(enabled);
|
||||
const dataManager = { isEnabled: (layerId) => layers.has(layerId) };
|
||||
const dataManager = suppliedManager || {
|
||||
isEnabled: (layerId) => layers.has(layerId),
|
||||
};
|
||||
// A layer that is off has no tracked aircraft, as the flight layers clear
|
||||
// tracking on disable.
|
||||
const trackedFrom = (layerId) => () =>
|
||||
tracked?.layerId === layerId && layers.has(layerId) ? tracked.info : null;
|
||||
const entity = {
|
||||
tracked?.layerId === layerId && dataManager.isEnabled(layerId)
|
||||
? tracked.info
|
||||
: null;
|
||||
const entityFor = (subject) => ({
|
||||
position: { getValue: () => null },
|
||||
show: true,
|
||||
gevTrackedId: tracked ? `${tracked.layerId}:${tracked.info.icao24}` : '',
|
||||
};
|
||||
gevTrackedId: subject ? `${subject.layerId}:${subject.info.icao24}` : '',
|
||||
});
|
||||
const entities = new Set();
|
||||
const entity = entityFor(tracked);
|
||||
entities.add(entity);
|
||||
const listeners = () => ({ addEventListener: () => () => {} });
|
||||
const viewer = {
|
||||
trackedEntity: tracked ? entity : undefined,
|
||||
clock: { currentTime: null },
|
||||
camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } },
|
||||
entities: { contains: (candidate) => candidate === entity },
|
||||
entities: { contains: (candidate) => entities.has(candidate) },
|
||||
scene: {
|
||||
preUpdate: listeners(),
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
@@ -86,7 +99,7 @@ function fixture(
|
||||
tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null,
|
||||
},
|
||||
readDataManager: () => dataManager,
|
||||
readContext: () => context,
|
||||
readContext: readContext || (() => context),
|
||||
readActiveStyle: () => 'normal',
|
||||
readUtilityDisclosureOpen: () => false,
|
||||
enterPanels() {},
|
||||
@@ -125,8 +138,14 @@ function fixture(
|
||||
{ type: 'visibility', layerId, enabled: false, origin: 'user' },
|
||||
);
|
||||
};
|
||||
/** Track another aircraft, as Contacts navigation does mid-session. */
|
||||
const retrack = (subject) => {
|
||||
tracked = subject;
|
||||
viewer.trackedEntity = entityFor(subject);
|
||||
entities.add(viewer.trackedEntity);
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return { $, coordinator, cockpit, store, layers, disable, pinia };
|
||||
return { $, coordinator, cockpit, store, layers, disable, retrack, pinia };
|
||||
}
|
||||
|
||||
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
|
||||
@@ -232,3 +251,217 @@ test('the readout offers the same entry, labelled by kind, and enters on click',
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter');
|
||||
});
|
||||
|
||||
const AIR_AND_SEA = [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
];
|
||||
|
||||
/** A source layer that settles at once, as the data manager drives it. */
|
||||
function sourceLayer(id) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
update() {},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts as the manager sees it: a coordinator whose real dependency
|
||||
* ownership (src/layers/awareness/dependencies.js) turns on the air and sea
|
||||
* layers it finds off and releases them when it goes off.
|
||||
*/
|
||||
function contactsCoordinator() {
|
||||
const state = {
|
||||
dataManager: null,
|
||||
enabled: false,
|
||||
passive: true,
|
||||
activationId: 0,
|
||||
ownedDependencies: new Set(),
|
||||
subject: null,
|
||||
autoFocusRetryPending: false,
|
||||
};
|
||||
const dependencies = createDependencies({
|
||||
state,
|
||||
services: {},
|
||||
parts: { subject: {}, focus: {} },
|
||||
source: null,
|
||||
});
|
||||
return {
|
||||
id: 'military-awareness',
|
||||
name: 'Contacts',
|
||||
icon: '',
|
||||
source: 'test',
|
||||
showInTogglePanel: false,
|
||||
updateInterval: -1,
|
||||
attachDataManager(dataManager) {
|
||||
state.dataManager = dataManager;
|
||||
dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
setParams(params = {}) {
|
||||
if (typeof params.passive === 'boolean') state.passive = params.passive;
|
||||
},
|
||||
getParams: () => ({ passive: state.passive }),
|
||||
init() {},
|
||||
enable() {
|
||||
state.enabled = true;
|
||||
if (!state.passive) return dependencies.enableDependencies();
|
||||
return true;
|
||||
},
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
state.passive = true;
|
||||
return dependencies.releaseOwnedDependencies(++state.activationId);
|
||||
},
|
||||
update() {},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cockpit beside the real Context owner and data manager, so a Contacts
|
||||
* exit runs its whole restore: the coordinator's releases, then the snapshot.
|
||||
*/
|
||||
async function liveFixture(t, { enabled = [], tracked = null } = {}) {
|
||||
const manager = new DataLayerManager({});
|
||||
for (const layerId of AIR_AND_SEA) manager.register(sourceLayer(layerId));
|
||||
const coordinatorModule = contactsCoordinator();
|
||||
manager.register(coordinatorModule);
|
||||
coordinatorModule.attachDataManager(manager);
|
||||
for (const layerId of enabled) await manager.setEnabled(layerId, true);
|
||||
let cockpit = null;
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => cockpit,
|
||||
claimVisualAuthority() {},
|
||||
showToast() {},
|
||||
setClearBusy() {},
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
refreshRadio() {},
|
||||
setPanelCollapsed() {},
|
||||
},
|
||||
});
|
||||
controls.connect(manager);
|
||||
t.after(() => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
});
|
||||
const base = fixture(t, {
|
||||
tracked,
|
||||
dataManager: manager,
|
||||
readContext: () => controls.getContextModeState(),
|
||||
});
|
||||
cockpit = base.cockpit;
|
||||
/** Let every Context reaction the last change started settle. */
|
||||
const settle = async () => {
|
||||
for (let turn = 0; turn < 5; turn += 1) {
|
||||
await controls._waitForContextLayerSettlement();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
return { ...base, manager, controls, settle };
|
||||
}
|
||||
|
||||
const on = (manager) =>
|
||||
[...AIR_AND_SEA, 'military-awareness'].filter((layerId) =>
|
||||
manager.isEnabled(layerId),
|
||||
);
|
||||
|
||||
test('Contacts ending in a Cockpit session keeps the ridden layer it had turned on', async (t) => {
|
||||
for (const [label, endContacts] of [
|
||||
[
|
||||
'Military Flights off',
|
||||
({ manager }) =>
|
||||
manager.setEnabled('military', false, { origin: 'user' }),
|
||||
],
|
||||
[
|
||||
'Contacts off by its own layer',
|
||||
({ manager }) =>
|
||||
manager.setEnabled('military-awareness', false, { origin: 'user' }),
|
||||
],
|
||||
[
|
||||
'Contacts off from its chooser',
|
||||
({ controls }) => controls._selectContextMode(null),
|
||||
],
|
||||
]) {
|
||||
await t.test(label, async (t) => {
|
||||
// Live Flights is off on the map; Contacts turns it on as its own.
|
||||
const live = await liveFixture(t, {
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, cockpit, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
assert.deepEqual(on(manager), [...AIR_AND_SEA, 'military-awareness']);
|
||||
assert.equal(cockpit.enter(), true);
|
||||
await endContacts(live);
|
||||
await settle();
|
||||
assert.equal(controls.getContextModeState().mode, null, 'Contacts ended');
|
||||
assert.equal(cockpit.active, true, 'the Cockpit rides on');
|
||||
assert.deepEqual(
|
||||
on(manager),
|
||||
['flights'],
|
||||
'only the ridden layer outlives the pre-Contacts restore',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('a ride handed to an aircraft on a layer Contacts turned on keeps that layer', async (t) => {
|
||||
const live = await liveFixture(t, {
|
||||
enabled: ['flights'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, cockpit, retrack, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
// NEXT hands the view to a military aircraft; Contacts turned its layer on.
|
||||
retrack({ layerId: 'military', info: MILITARY });
|
||||
assert.equal(cockpit._adoptTrackedEntity(performance.now()), true);
|
||||
assert.equal(cockpit.readSubjectLayerId(), 'military');
|
||||
await manager.setEnabled('ais-live-vessels', false, { origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(controls.getContextModeState().mode, null);
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.deepEqual(on(manager), ['flights', 'military']);
|
||||
});
|
||||
|
||||
test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => {
|
||||
const live = await liveFixture(t, {
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, cockpit, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
await manager.setEnabled('flights', false, { origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.equal(manager.isEnabled('flights'), false);
|
||||
});
|
||||
|
||||
test('without a Cockpit, ending Contacts restores the layers it found', async (t) => {
|
||||
const live = await liveFixture(t, {
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
await manager.setEnabled('military', false, { origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(controls.getContextModeState().mode, null);
|
||||
assert.deepEqual(on(manager), [], 'Live Flights goes back off');
|
||||
});
|
||||
@@ -74,6 +74,27 @@ export function dropCockpitSignal(key) {
|
||||
this.publishCockpitSignals();
|
||||
}
|
||||
|
||||
/** Whether a row is one a Contacts snapshot builds, with its status rows. */
|
||||
const isContextSnapshotRow = (key) =>
|
||||
key.startsWith('flight:') ||
|
||||
['input-status', 'feed-status', 'context-status'].includes(key);
|
||||
|
||||
/**
|
||||
* Take off every row a Contacts snapshot built, as when Contacts goes off:
|
||||
* its contacts, distances and CURRENT would otherwise read as live. Rows the
|
||||
* session made without Contacts, such as TRACK ACQUIRED, stay.
|
||||
*/
|
||||
export function dropContextSnapshotSignals() {
|
||||
for (const key of [...this.signalSignatures.keys()])
|
||||
if (isContextSnapshotRow(key)) this.signalSignatures.delete(key);
|
||||
const kept = this.signalItems.filter(
|
||||
(item) => !isContextSnapshotRow(item.key),
|
||||
);
|
||||
if (kept.length === this.signalItems.length) return;
|
||||
this.signalItems = kept;
|
||||
this.publishCockpitSignals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the rows from a Contacts snapshot: the nearest flights, a row for
|
||||
* unknown inputs and, first, a row naming an air feed that is off.
|
||||
|
||||
@@ -155,6 +155,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.onRide?.(info.layerId);
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
@@ -230,6 +231,7 @@ export function enter() {
|
||||
this.mapViewButton?.focus({ preventScroll: true });
|
||||
});
|
||||
this.onEntered?.();
|
||||
this.onRide?.(info.layerId);
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user