mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
feat(cockpit): enter Cockpit with only the ridden aircraft's layer on
Cockpit entry no longer needs Contacts or both flight feeds: a civilian aircraft needs Live Flights, a military one Military Flights. Contacts and every other layer can be turned on or off before or during a session, and turning off the ridden layer ends it. A Context mode change in flight still blocks entry. When the other air feed is off, Live Signals and the Contact card say FLIGHTS FEED OFF or MILITARY FEED OFF · PARTIAL PICTURE instead of presenting a partial picture as complete. Voice refuses entry with subject-layer-off rather than contacts-inactive, and its tool description says Contacts is not required. Double-clicking the followed aircraft enters Cockpit through the same gate. The Cockpit takes the scene's double-click before Cesium's stock handler, which would otherwise untrack a followed aircraft whose model is a primitive. The entry also shows under the Intel HUD readout, sharing the Contacts button's face. A test keeps the classes the Cockpit adds to the body from matching another element's rule.
This commit is contained in:
1 parent
82f17e694e
commit
219f3e1079
37 files changed
+964
-134
No files matched your search
+11
-7
@@ -4799,7 +4799,7 @@ cannot claim full acceptance.
|
||||
|
||||
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
|
||||
prior state on exit; Space Missions refuses enablement while isolating replay
|
||||
data. Cockpit enters through Contacts and does not independently enable Transit.
|
||||
data. Cockpit does not need Contacts and does not independently enable Transit.
|
||||
The proxy fetches registered URLs only, validates redirect origins, bounds
|
||||
responses and decoding, caches successful snapshots, and backs off after failed
|
||||
refreshes. A 304 confirms contact without appending history. Missing vehicles
|
||||
@@ -6017,12 +6017,16 @@ apply to `public/` assets or built-preview serving.
|
||||
- Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14).
|
||||
- The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver.
|
||||
- The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract.
|
||||
- Cockpit entry is gated to the operational Contacts context bundle. The
|
||||
Context chooser must be in Contacts mode, Live Flights and Military Flights must both
|
||||
be enabled, and a civilian or military aircraft must be tracked. The visible
|
||||
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
|
||||
flight-layer selection cannot enter a context-dependent cockpit.
|
||||
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). 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, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session.
|
||||
- Cockpit entry needs only the layer of the aircraft it rides and that aircraft
|
||||
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.
|
||||
- **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.
|
||||
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"src/ui/views/panels/CockpitBriefing.vue",
|
||||
"src/ui/views/panels/CockpitContext.vue",
|
||||
"src/ui/views/panels/CockpitRoute.vue",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
"src/data/headingDegrees.js",
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/views/panels/CockpitRollingValue.vue",
|
||||
"src/ui/views/panels/CockpitSignalList.vue",
|
||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||
@@ -866,6 +869,7 @@
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
@@ -992,6 +996,7 @@
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
"src/ui/cockpitPresentation.js",
|
||||
|
||||
@@ -2321,9 +2321,11 @@ async function runBehaviorLayer() {
|
||||
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
|
||||
);
|
||||
|
||||
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the
|
||||
// reason rather than produce the half-entered state the operator saw: a
|
||||
// plane anchored under the camera with no HUD and no exit control.
|
||||
// (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
|
||||
// With Contacts OFF, status must explain a shut gate without blaming
|
||||
// Contacts, and entry must either enter cleanly or refuse with the reason
|
||||
// rather than produce the half-entered state the operator saw: a plane
|
||||
// anchored under the camera with no HUD and no exit control.
|
||||
r = await run('control_cockpit', { action: 'status' });
|
||||
const gateReport = r?.state || {};
|
||||
const cockpitBeforeGate = await page.evaluate(() =>
|
||||
@@ -2331,10 +2333,13 @@ async function runBehaviorLayer() {
|
||||
);
|
||||
report(
|
||||
gateReport.active === false &&
|
||||
gateReport.entryAllowed === false &&
|
||||
gateReport.entryBlockedReason === 'contacts-inactive' &&
|
||||
(gateReport.entryAllowed === true
|
||||
? gateReport.entryBlockedReason === null
|
||||
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
|
||||
gateReport.entryBlockedReason,
|
||||
)) &&
|
||||
!cockpitBeforeGate,
|
||||
'behavior: cockpit status names why entry is blocked with Contacts off',
|
||||
'behavior: cockpit status explains its gate without requiring Contacts',
|
||||
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
|
||||
);
|
||||
|
||||
@@ -2348,12 +2353,16 @@ async function runBehaviorLayer() {
|
||||
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
||||
);
|
||||
report(
|
||||
gated?.ok === false &&
|
||||
/contacts/i.test(gated?.error || '') &&
|
||||
!cockpitAfterGate,
|
||||
'behavior: cockpit entry with Contacts off is refused, not half-entered',
|
||||
gated?.ok === true
|
||||
? cockpitAfterGate
|
||||
: !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
|
||||
'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered',
|
||||
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
||||
);
|
||||
if (cockpitAfterGate)
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
|
||||
);
|
||||
|
||||
await runBundledOutlineScenarios({
|
||||
page,
|
||||
|
||||
@@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
control_cockpit: {
|
||||
description:
|
||||
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
|
||||
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
|
||||
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.ok(branch, 'cockpit enter branch is missing');
|
||||
const gateIndex = branch.indexOf('isEntryAllowed?.()');
|
||||
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
|
||||
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
|
||||
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
||||
assert.ok(
|
||||
gateIndex >= 0,
|
||||
'entry consults the same gate the manual chip uses',
|
||||
'entry refuses on the gate it can check before tracking',
|
||||
);
|
||||
assert.ok(
|
||||
retargetIndex > gateIndex,
|
||||
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
|
||||
ui.indexOf("if (normalized === 'enter') {"),
|
||||
ui.indexOf("if (normalized === 'exit') {"),
|
||||
);
|
||||
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
|
||||
assert.match(branch, /Contacts must be active to enter Cockpit/);
|
||||
assert.match(branch, /Contacts is still starting up/);
|
||||
// Contacts is not required: only a Context change in flight or no aircraft
|
||||
// layer to ride shuts the gate before tracking, and the entry itself then
|
||||
// applies the manual chip's gate to the aircraft it rides.
|
||||
assert.match(
|
||||
branch,
|
||||
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
|
||||
);
|
||||
assert.doesNotMatch(branch, /Contacts must be active/);
|
||||
const refusal = ui.slice(
|
||||
ui.indexOf(' _cockpitEntryRefusal() {'),
|
||||
ui.indexOf(' getCockpitState() {'),
|
||||
);
|
||||
assert.match(refusal, /Context is still changing/);
|
||||
assert.match(refusal, /No aircraft layer is on/);
|
||||
assert.doesNotMatch(refusal, /[Cc]ontacts/);
|
||||
});
|
||||
|
||||
test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
|
||||
);
|
||||
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||
assert.match(state, /'contacts-starting'/);
|
||||
assert.match(state, /'contacts-inactive'/);
|
||||
assert.match(state, /'subject-layer-off'/);
|
||||
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
|
||||
assert.match(state, /'no-tracked-aircraft'/);
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
|
||||
],
|
||||
});
|
||||
|
||||
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
|
||||
const f = fixture(t);
|
||||
const crowded = {
|
||||
...snapshot(),
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
count: 6,
|
||||
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
|
||||
id: `n${n}`,
|
||||
label: `N${n}`,
|
||||
distanceM: n * 1000,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
updateCockpitSignals.call(f.owner, crowded, 0, {
|
||||
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(f.owner.signalItems.length, 5);
|
||||
assert.equal(f.owner.signalItems[0].key, 'feed-status');
|
||||
assert.equal(f.owner.signalItems[0].tone, 'warning');
|
||||
assert.equal(f.owner.signalItems[0].target, null);
|
||||
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
|
||||
updateCockpitSignals.call(f.owner, crowded, 0);
|
||||
assert.equal(
|
||||
f.owner.signalItems.some((item) => item.key === 'feed-status'),
|
||||
false,
|
||||
'with the feed back, the row goes',
|
||||
);
|
||||
});
|
||||
|
||||
test('rows render the signals the controller presents', async (t) => {
|
||||
const f = fixture(t);
|
||||
await render(f, [
|
||||
|
||||
+23
-16
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** The layers an aircraft Cockpit subject can come from. */
|
||||
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
|
||||
* aircraft alone is not sufficient: both observed-flight feeds must still be
|
||||
* active so the cockpit's surrounding-contact picture is not presented as a
|
||||
* complete context view when one source is disabled.
|
||||
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
||||
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
||||
* be on. Contacts and the other feeds are not required. When a source the
|
||||
* surrounding picture uses is off, the Cockpit's Contact card and Live
|
||||
* Signals say the picture is partial instead of blocking entry, so a partial
|
||||
* picture is never presented as complete. A Context mode change in flight
|
||||
* still owns the camera, so entry waits it out.
|
||||
*
|
||||
* @param {object} input Current context and dependency visibility.
|
||||
* @param {string|null} input.contextMode Active Context mode.
|
||||
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
|
||||
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
|
||||
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
|
||||
* @param {object} input Current subject and Context state.
|
||||
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
||||
* ('flights' or 'military'), or null with none tracked.
|
||||
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
||||
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
||||
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
||||
*/
|
||||
export function cockpitEntryAllowed({
|
||||
contextMode,
|
||||
subjectLayerId,
|
||||
subjectLayerEnabled,
|
||||
contextModeChanging,
|
||||
flightsEnabled,
|
||||
militaryEnabled,
|
||||
}) {
|
||||
return (
|
||||
contextMode === 'flights' &&
|
||||
!contextModeChanging &&
|
||||
Boolean(flightsEnabled) &&
|
||||
Boolean(militaryEnabled)
|
||||
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
||||
Boolean(subjectLayerEnabled) &&
|
||||
!contextModeChanging
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
|
||||
}
|
||||
});
|
||||
|
||||
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
|
||||
test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), true);
|
||||
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), true, 'a military aircraft needs Military Flights alone');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'flights',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: true,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'Cockpit remains unavailable until Contacts activation settles');
|
||||
}), false, 'Cockpit remains unavailable until a Context mode change settles');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: null,
|
||||
subjectLayerId: 'military',
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: true,
|
||||
}), false, 'ordinary layer selection is not an operational Contacts context');
|
||||
}), false, "the ridden aircraft's own layer is required");
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
subjectLayerId: null,
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: true,
|
||||
militaryEnabled: false,
|
||||
}), false, 'the military contact feed is required');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
contextMode: 'flights',
|
||||
contextModeChanging: false,
|
||||
flightsEnabled: false,
|
||||
militaryEnabled: true,
|
||||
}), false, 'the civilian contact feed is required');
|
||||
}), false, 'there is no aircraft to ride');
|
||||
});
|
||||
|
||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||
|
||||
@@ -1039,10 +1039,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
|
||||
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
|
||||
// Pistes extend the generated layer enums and alias hint; voice geometry
|
||||
// adds the analyst area scopes and the osm-places layer.
|
||||
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
|
||||
// control_cockpit states the own-layer entry rule (Contacts not required).
|
||||
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
|
||||
assert.equal(
|
||||
crypto.createHash('sha256').update(block).digest('hex'),
|
||||
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
|
||||
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
|
||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||
);
|
||||
const instructions = fs.readFileSync(
|
||||
|
||||
@@ -2187,6 +2187,9 @@
|
||||
"{value} KM/H": "{value} كم/س",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
|
||||
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
|
||||
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
|
||||
|
||||
@@ -2087,6 +2087,9 @@
|
||||
"{value} KM/H": "{value} KM/H",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
|
||||
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
|
||||
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
|
||||
|
||||
@@ -1920,6 +1920,9 @@
|
||||
"N/A": "N/D",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
|
||||
"ft": "ft",
|
||||
"kts": "kn",
|
||||
|
||||
@@ -1660,6 +1660,9 @@
|
||||
"REGIONAL NEWS": "ACTUS RÉGIONALES",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
|
||||
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
|
||||
|
||||
@@ -1660,6 +1660,9 @@
|
||||
"REGIONAL NEWS": "NOTIZIE REGIONALI",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
|
||||
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
|
||||
|
||||
@@ -1612,6 +1612,9 @@
|
||||
"REGIONAL NEWS": "地域ニュース",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
|
||||
"CONTEXT STANDBY": "コンテキスト待機",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
|
||||
@@ -1684,7 +1687,7 @@
|
||||
"Restore real aircraft": "実際の航空機に戻す",
|
||||
"Reclassify as TR-3B": "TR-3Bに再分類",
|
||||
"TRACK ACQUIRED": "追跡開始",
|
||||
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
|
||||
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
|
||||
"No selected data layers": "選択中のデータレイヤーはありません",
|
||||
"{count} data layers could not be cleared": {
|
||||
|
||||
@@ -1660,6 +1660,9 @@
|
||||
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
|
||||
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
|
||||
@@ -1736,7 +1739,7 @@
|
||||
"Restore real aircraft": "Restaurar a aeronave real",
|
||||
"Reclassify as TR-3B": "Reclassificar como TR-3B",
|
||||
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
|
||||
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
|
||||
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
|
||||
"No selected data layers": "Nenhuma camada de dados selecionada",
|
||||
"{count} data layers could not be cleared": {
|
||||
|
||||
@@ -458,7 +458,7 @@
|
||||
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
|
||||
"Select flight {title}": "Выбрать рейс {title}",
|
||||
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
|
||||
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
|
||||
"Restore real aircraft": "Вернуть реальный самолёт",
|
||||
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
|
||||
"No selected data layers": "Нет выбранных слоёв данных",
|
||||
@@ -1953,6 +1953,9 @@
|
||||
"N/A": "Н/Д",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
|
||||
"ft": "фт",
|
||||
"kts": "уз",
|
||||
|
||||
@@ -1683,6 +1683,9 @@
|
||||
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||
@@ -1761,7 +1764,7 @@
|
||||
"Restore real aircraft": "Повернути справжній літак",
|
||||
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
|
||||
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
|
||||
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
|
||||
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
|
||||
"No selected data layers": "Шари даних не вибрано",
|
||||
"{count} data layers could not be cleared": {
|
||||
|
||||
@@ -1588,6 +1588,9 @@
|
||||
"REGIONAL NEWS": "地区新闻",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
|
||||
"CONTEXT STANDBY": "态势待机",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
|
||||
@@ -1660,7 +1663,7 @@
|
||||
"Restore real aircraft": "恢复真实飞机",
|
||||
"Reclassify as TR-3B": "重新归类为 TR-3B",
|
||||
"TRACK ACQUIRED": "已获取航迹",
|
||||
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
|
||||
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
|
||||
"No selected data layers": "没有已选数据图层",
|
||||
"{count} data layers could not be cleared": {
|
||||
|
||||
@@ -1578,6 +1578,9 @@
|
||||
"REGIONAL NEWS": "地區新聞",
|
||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
|
||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
|
||||
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
|
||||
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
|
||||
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
|
||||
"CONTEXT STANDBY": "態勢待機",
|
||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
|
||||
@@ -1650,7 +1653,7 @@
|
||||
"Restore real aircraft": "恢復真實飛機",
|
||||
"Reclassify as TR-3B": "重新歸類為 TR-3B",
|
||||
"TRACK ACQUIRED": "已獲取航跡",
|
||||
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
|
||||
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
|
||||
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
|
||||
"No selected data layers": "沒有已選數據圖層",
|
||||
"{count} data layers could not be cleared": {
|
||||
|
||||
@@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
|
||||
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
|
||||
const SPAN_TEXT =
|
||||
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
|
||||
/** A glyph chosen in a Vue template: `<span class="material-symbols-outlined">{{ a ? 'x' : 'y' }}</span>`. */
|
||||
const SPAN_MUSTACHE =
|
||||
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*</g;
|
||||
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
|
||||
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
|
||||
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
|
||||
@@ -64,7 +67,24 @@ function referencedGlyphs() {
|
||||
};
|
||||
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
|
||||
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
|
||||
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
|
||||
// A template glyph bound to a computed (`{{ entryIcon }}`) is whatever
|
||||
// that computed returns.
|
||||
for (const [, expression] of source.matchAll(SPAN_MUSTACHE)) {
|
||||
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expression)?.[1];
|
||||
if (!name) continue;
|
||||
const body = new RegExp(
|
||||
`\\b${name}\\s*=\\s*computed\\(\\(\\)\\s*=>\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`,
|
||||
).exec(source)?.[1];
|
||||
for (const literal of body?.matchAll(
|
||||
/\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g,
|
||||
) ?? [])
|
||||
add(literal[1]);
|
||||
}
|
||||
const expressions = [
|
||||
...source.matchAll(TEXT_ASSIGNMENT),
|
||||
...source.matchAll(SPAN_MUSTACHE),
|
||||
];
|
||||
for (const match of expressions) {
|
||||
const statement = match[1].replaceAll('?.', '.');
|
||||
const assigned = statement.includes('?')
|
||||
? statement.slice(statement.indexOf('?') + 1)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on
|
||||
// <body> must not also name an element's rule. A bare `.name { … }` written
|
||||
// for a panel matches <body> the moment <body> carries `name`, and its
|
||||
// pointer-events, position and overflow then apply to the whole page.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const UI_ROOT = fileURLToPath(new URL('.', import.meta.url));
|
||||
const SRC_ROOT = path.resolve(UI_ROOT, '..');
|
||||
const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js');
|
||||
|
||||
/** Every stylesheet the app ships. */
|
||||
function stylesheets(directory = SRC_ROOT) {
|
||||
const files = [];
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolute = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...stylesheets(absolute));
|
||||
else if (entry.isFile() && entry.name.endsWith('.css'))
|
||||
files.push(absolute);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** A selector list split on its top-level commas, not those in `:not(a, b)`. */
|
||||
function splitSelectorList(list) {
|
||||
const parts = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
if (list[i] === '(') depth += 1;
|
||||
else if (list[i] === ')') depth -= 1;
|
||||
else if (list[i] === ',' && depth === 0) {
|
||||
parts.push(list.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(list.slice(start));
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The classes the tracking controller adds to <body>. */
|
||||
function cockpitBodyClasses() {
|
||||
const source = readFileSync(CONTROLLER, 'utf8');
|
||||
const names = [
|
||||
...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g),
|
||||
].map((match) => match[1]);
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
test('the Cockpit adds its subject classes to <body>', () => {
|
||||
const classes = cockpitBodyClasses();
|
||||
for (const name of ['cockpit-mode']) {
|
||||
assert.ok(classes.includes(name), `${name} is not added to <body>`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no stylesheet rule matches a Cockpit body class on another element', () => {
|
||||
const offenders = [];
|
||||
for (const name of cockpitBodyClasses()) {
|
||||
// `.name` as a whole class token, not as `.name-suffix`.
|
||||
const token = new RegExp(`\\.${name}(?![\\w-])`);
|
||||
for (const file of stylesheets()) {
|
||||
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) {
|
||||
for (const selector of splitSelectorList(selectors)) {
|
||||
// Prettier wraps long `:not(\n .name\n)` arguments; unwrap them.
|
||||
const compound = selector
|
||||
.trim()
|
||||
.replace(/\(\s+/g, '(')
|
||||
.replace(/\s+\)/g, ')');
|
||||
if (!token.test(compound)) continue;
|
||||
// Allowed only on <body> itself: `body.name` or `body:not(.name)`.
|
||||
const onBody = new RegExp(
|
||||
`(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`,
|
||||
);
|
||||
if (onBody.test(compound)) continue;
|
||||
offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Selectors that match a Cockpit body class without `body.`: ' +
|
||||
offenders.join('; '),
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Which air feed the aircraft Cockpit's surrounding picture is missing. Entry
|
||||
* needs only the ridden aircraft's own layer, so the other one may be off;
|
||||
* the panels then say the picture is partial rather than present it as
|
||||
* complete. Null with both feeds on, or when the owner cannot tell.
|
||||
* @returns {?{title: string, detail: string}}
|
||||
*/
|
||||
export function readPartialPicture() {
|
||||
const isLayerEnabled = this.services.isLayerEnabled;
|
||||
if (typeof isLayerEnabled !== 'function') return null;
|
||||
const militaryOff = !isLayerEnabled('military');
|
||||
const flightsOff = !isLayerEnabled('flights');
|
||||
if (!militaryOff && !flightsOff) return null;
|
||||
return {
|
||||
title: militaryOff
|
||||
? t('MILITARY FEED OFF · PARTIAL PICTURE')
|
||||
: t('FLIGHTS FEED OFF · PARTIAL PICTURE'),
|
||||
detail: t('NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Hand a fresh copy of the Contact card to the view. */
|
||||
export function publishContextView() {
|
||||
const view = this.contextView;
|
||||
@@ -52,6 +73,7 @@ export function updateContext(info, heading) {
|
||||
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
|
||||
const trackedId = info.icao24 || info.id;
|
||||
const readout = resolveCockpitContextReadout({ snapshot, info });
|
||||
const partial = readPartialPicture.call(this);
|
||||
if (!readout.visible) {
|
||||
this.showContextCard(false);
|
||||
this.contextLayoutStamp = null;
|
||||
@@ -61,6 +83,14 @@ export function updateContext(info, heading) {
|
||||
t('CONTEXT STANDBY'),
|
||||
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
|
||||
);
|
||||
if (partial)
|
||||
this.pushCockpitSignal(
|
||||
'feed-status',
|
||||
'warning',
|
||||
partial.title,
|
||||
partial.detail,
|
||||
);
|
||||
else this.dropCockpitSignal?.('feed-status');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,19 +201,22 @@ export function updateContext(info, heading) {
|
||||
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
||||
});
|
||||
}
|
||||
view.uncertainty = unknownCount
|
||||
? tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
||||
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
|
||||
)
|
||||
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
|
||||
// A feed that is off outranks unknown inputs: the picture is partial.
|
||||
view.uncertainty = partial
|
||||
? partial.title
|
||||
: unknownCount
|
||||
? tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
||||
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
|
||||
)
|
||||
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
|
||||
view.updated = Number.isFinite(snapshot.evaluatedAt)
|
||||
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
|
||||
: '--:--:--Z';
|
||||
view.state = unknownCount ? 'uncertain' : 'current';
|
||||
view.state = unknownCount || partial ? 'uncertain' : 'current';
|
||||
this.publishContextView();
|
||||
this.updateCockpitSignals(snapshot, unknownCount);
|
||||
this.updateCockpitSignals(snapshot, unknownCount, partial);
|
||||
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
||||
this.contextLayoutStamp = snapshot.evaluatedAt;
|
||||
this.scheduleContextLayout();
|
||||
|
||||
@@ -204,6 +204,63 @@ test('without a snapshot the card stands by, hidden', async (t) => {
|
||||
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
|
||||
});
|
||||
|
||||
test('a feed that is off makes the card and Live Signals say the picture is partial', async (t) => {
|
||||
// Every input known, so only the feed that is off can make it partial.
|
||||
const { $, text, owner } = fixture(
|
||||
t,
|
||||
snapshot({
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: 'Flights',
|
||||
count: 12,
|
||||
relationship: 'OBSERVED',
|
||||
nearest: [{ id: 'N1', distanceM: 4200 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const rebuilt = [];
|
||||
owner.updateCockpitSignals = (...args) => rebuilt.push(args);
|
||||
owner.services.isLayerEnabled = () => true;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').dataset.state, 'current');
|
||||
assert.equal(rebuilt.at(-1)[2], null, 'both feeds on: nothing to add');
|
||||
|
||||
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').dataset.state, 'uncertain');
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
);
|
||||
assert.deepEqual(rebuilt.at(-1)[2], {
|
||||
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
});
|
||||
});
|
||||
|
||||
test('without Contacts, a feed that is off adds a signal until it is back', async (t) => {
|
||||
const { owner, signals } = fixture(t, null);
|
||||
const dropped = [];
|
||||
owner.dropCockpitSignal = (key) => dropped.push(key);
|
||||
// Riding a military aircraft with Live Flights off.
|
||||
owner.services.isLayerEnabled = (layerId) => layerId === 'military';
|
||||
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
|
||||
assert.equal(signals.at(-2)[2], 'CONTEXT STANDBY');
|
||||
assert.deepEqual(signals.at(-1), [
|
||||
'feed-status',
|
||||
'warning',
|
||||
'FLIGHTS FEED OFF · PARTIAL PICTURE',
|
||||
'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
|
||||
]);
|
||||
owner.services.isLayerEnabled = () => true;
|
||||
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
|
||||
assert.deepEqual(dropped, ['feed-status']);
|
||||
});
|
||||
|
||||
test('the weather toggle renders its state and the action it offers', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
syncWeatherToggle.call(owner, true);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
|
||||
import {
|
||||
readAircraftInfo,
|
||||
readSubjectLayerId,
|
||||
resolveEntryKind,
|
||||
dispatchCockpitModeChanged,
|
||||
toggleTrackedTr3b,
|
||||
syncTr3bToggle,
|
||||
@@ -48,6 +50,7 @@ import {
|
||||
relocalizeBrief,
|
||||
} from './cockpitBriefing.js';
|
||||
import {
|
||||
dropCockpitSignal,
|
||||
handleSignalClick,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
@@ -60,7 +63,11 @@ import {
|
||||
syncContextLayout,
|
||||
syncSignalLayout,
|
||||
} from './cockpitLayout.js';
|
||||
import { onKeyDown } from './cockpitInput.js';
|
||||
import {
|
||||
onKeyDown,
|
||||
onSceneDoubleClick,
|
||||
routeSceneDoubleClick,
|
||||
} from './cockpitInput.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export class CockpitViewController {
|
||||
@@ -205,6 +212,13 @@ export class CockpitViewController {
|
||||
}),
|
||||
);
|
||||
this._listen(this.entry, 'click', () => this.enter());
|
||||
// Double-clicking the followed track is a third way in.
|
||||
this._listenerRemovers.push(routeSceneDoubleClick.call(this));
|
||||
// The readout's entry renders inside the Intel HUD, which may mount after
|
||||
// this controller, so its clicks are delegated.
|
||||
this._listen(document, 'click', (event) => {
|
||||
if (event.target?.closest?.('#cockpit-entry-readout')) this.enter();
|
||||
});
|
||||
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
|
||||
this._listen(this.mapViewButton, 'click', () => this.exit());
|
||||
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
|
||||
@@ -272,6 +286,12 @@ export class CockpitViewController {
|
||||
readAircraftInfo() {
|
||||
return readAircraftInfo.call(this);
|
||||
}
|
||||
readSubjectLayerId() {
|
||||
return readSubjectLayerId.call(this);
|
||||
}
|
||||
resolveEntryKind() {
|
||||
return resolveEntryKind.call(this);
|
||||
}
|
||||
dispatchCockpitModeChanged(active, info) {
|
||||
return dispatchCockpitModeChanged.call(this, active, info);
|
||||
}
|
||||
@@ -371,8 +391,16 @@ export class CockpitViewController {
|
||||
pushCockpitSignal(key, tone, title, detail, target) {
|
||||
return pushCockpitSignal.call(this, key, tone, title, detail, target);
|
||||
}
|
||||
updateCockpitSignals(snapshot, unknownCount) {
|
||||
return updateCockpitSignals.call(this, snapshot, unknownCount);
|
||||
dropCockpitSignal(key) {
|
||||
return dropCockpitSignal.call(this, key);
|
||||
}
|
||||
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
|
||||
return updateCockpitSignals.call(
|
||||
this,
|
||||
snapshot,
|
||||
unknownCount,
|
||||
partialPicture,
|
||||
);
|
||||
}
|
||||
scheduleContextLayout() {
|
||||
return scheduleContextLayout.call(this);
|
||||
@@ -392,6 +420,9 @@ export class CockpitViewController {
|
||||
onKeyDown(event) {
|
||||
return onKeyDown.call(this, event);
|
||||
}
|
||||
onSceneDoubleClick(position) {
|
||||
return onSceneDoubleClick.call(this, position);
|
||||
}
|
||||
|
||||
handleSignalClick(event) {
|
||||
return handleSignalClick.call(this, event);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { nextTick } from 'vue';
|
||||
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
|
||||
import { cockpitEntryAllowed } from '../contextModePolicy.js';
|
||||
import {
|
||||
COCKPIT_AIRCRAFT_LAYER_IDS,
|
||||
cockpitEntryAllowed,
|
||||
} from '../contextModePolicy.js';
|
||||
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
|
||||
import { enterCockpitWithTracking } from '../cockpitTracking.js';
|
||||
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
|
||||
@@ -72,19 +75,24 @@ export class CockpitCoordinator {
|
||||
fetchRegionalBrief,
|
||||
regionalDistanceM,
|
||||
weatherCodeLabel,
|
||||
// The Contact card and Live Signals say which feeds are off.
|
||||
isLayerEnabled: (layerId) => this._isLayerEnabled(layerId),
|
||||
},
|
||||
onVisionChange: (mode, active, options) =>
|
||||
this._setCockpitVision(mode, active, options),
|
||||
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
|
||||
onCameraTakeover: () =>
|
||||
this._stampNavigation({ cancelPendingSelection: false }),
|
||||
isEntryAllowed: () =>
|
||||
cockpitEntryAllowed({
|
||||
contextMode: this._contextMode,
|
||||
// Only the tracked aircraft's own layer gates entry (see the policy).
|
||||
isEntryAllowed: () => {
|
||||
const subjectLayerId =
|
||||
this.cockpitView?.readAircraftInfo?.()?.layerId ?? null;
|
||||
return cockpitEntryAllowed({
|
||||
subjectLayerId,
|
||||
subjectLayerEnabled: this._isLayerEnabled(subjectLayerId),
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||
}),
|
||||
});
|
||||
},
|
||||
publish: this.publishCockpit,
|
||||
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
|
||||
onEntered: () => this.enterPanels(),
|
||||
@@ -115,6 +123,28 @@ export class CockpitCoordinator {
|
||||
get activeStyle() {
|
||||
return this.readActiveStyle();
|
||||
}
|
||||
/** Whether a data layer is enabled; false for no layer. */
|
||||
_isLayerEnabled(layerId) {
|
||||
return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId);
|
||||
}
|
||||
/** Whether either aircraft layer is on, so there is an aircraft to ride. */
|
||||
get _aircraftLayerEnabled() {
|
||||
return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) =>
|
||||
this._isLayerEnabled(layerId),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Why voice entry must refuse before it touches tracking, or null. The
|
||||
* tracked aircraft's own layer is checked again by the entry itself.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
_cockpitEntryRefusal() {
|
||||
if (this._contextModeChanging)
|
||||
return 'Context is still changing — try Cockpit again in a moment';
|
||||
if (!this._aircraftLayerEnabled)
|
||||
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
|
||||
return null;
|
||||
}
|
||||
getCockpitState() {
|
||||
const { militaryAwarenessLayer } = this.services;
|
||||
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
|
||||
@@ -129,19 +159,24 @@ export class CockpitCoordinator {
|
||||
const entryAllowed =
|
||||
!active &&
|
||||
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
|
||||
// Entry needs only the ridden aircraft's own layer: with one tracked,
|
||||
// that layer; with none, any aircraft layer to track one from.
|
||||
const subjectLayerOff = info
|
||||
? !this._isLayerEnabled(info.layerId)
|
||||
: !this._aircraftLayerEnabled;
|
||||
return {
|
||||
active,
|
||||
entryAllowed,
|
||||
// Why entry is unavailable, so a refusal can be explained rather than
|
||||
// guessed at.
|
||||
// guessed at. Contacts is not required, so it is never the reason.
|
||||
entryBlockedReason:
|
||||
entryAllowed || active
|
||||
? null
|
||||
: !gateOpen
|
||||
? this._contextModeChanging
|
||||
? 'contacts-starting'
|
||||
: 'contacts-inactive'
|
||||
: 'no-tracked-aircraft',
|
||||
: this._contextModeChanging
|
||||
? 'contacts-starting'
|
||||
: subjectLayerOff
|
||||
? 'subject-layer-off'
|
||||
: 'no-tracked-aircraft',
|
||||
visionMode: this.cockpitView?.visionMode || null,
|
||||
subject: info
|
||||
? {
|
||||
@@ -260,17 +295,18 @@ export class CockpitCoordinator {
|
||||
};
|
||||
}
|
||||
if (normalized === 'enter') {
|
||||
// Entry is gated exactly as the manual entry chip is. Attempting it while
|
||||
// the gate is shut produced the half-entered look the operator reported
|
||||
// (a plane anchored under the camera with no cockpit around it), so
|
||||
// refuse with the reason instead of trying.
|
||||
if (!this.cockpitView.isEntryAllowed?.()) {
|
||||
// Attempting entry while the gate is shut produced the half-entered look
|
||||
// the operator reported (a plane anchored under the camera with no
|
||||
// cockpit around it), so refuse with the reason instead of trying. The
|
||||
// aircraft may not be tracked yet (a selection or a requested layer is
|
||||
// adopted below), so this checks what can be known now; the entry
|
||||
// itself then applies the manual chip's gate to the aircraft it rides.
|
||||
const refusal = this._cockpitEntryRefusal();
|
||||
if (refusal) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'control_cockpit',
|
||||
error: this._contextModeChanging
|
||||
? 'Contacts is still starting up — try Cockpit again in a moment'
|
||||
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
|
||||
error: refusal,
|
||||
state: this.getCockpitState(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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.
|
||||
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 { CockpitCoordinator } from './cockpitCoordinator.js';
|
||||
import { _handleContextLayerChange } from './contextLayerChanges.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import { useHudStore } from './stores/hud.js';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
import IntelHudView from './views/IntelHudView.vue';
|
||||
|
||||
const CIVIL = Object.freeze({
|
||||
icao24: 'abc123',
|
||||
callsign: 'UAL123',
|
||||
track: 92,
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
});
|
||||
const MILITARY = Object.freeze({
|
||||
...CIVIL,
|
||||
icao24: 'ae0001',
|
||||
callsign: 'RCH1',
|
||||
});
|
||||
|
||||
function fixture(
|
||||
t,
|
||||
{
|
||||
enabled = [],
|
||||
tracked = null,
|
||||
contextMode = null,
|
||||
changing = false,
|
||||
} = {},
|
||||
) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(
|
||||
defineComponent(() => () => [h(ContextView), h(IntelHudView)]),
|
||||
).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useCockpitStore(pinia);
|
||||
const layers = new Set(enabled);
|
||||
const dataManager = { 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 = {
|
||||
position: { getValue: () => null },
|
||||
show: true,
|
||||
gevTrackedId: tracked ? `${tracked.layerId}:${tracked.info.icao24}` : '',
|
||||
};
|
||||
const listeners = () => ({ addEventListener: () => () => {} });
|
||||
const viewer = {
|
||||
trackedEntity: tracked ? entity : undefined,
|
||||
clock: { currentTime: null },
|
||||
camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } },
|
||||
entities: { contains: (candidate) => candidate === entity },
|
||||
scene: {
|
||||
preUpdate: listeners(),
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
},
|
||||
trackedEntityChanged: listeners(),
|
||||
};
|
||||
const context = { mode: contextMode, changing };
|
||||
const coordinator = new CockpitCoordinator({
|
||||
viewer,
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
|
||||
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
|
||||
militaryAwarenessLayer: { getContextSnapshot: () => null },
|
||||
isTr3b: () => false,
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
},
|
||||
elements: {},
|
||||
operations: {
|
||||
publishCockpit: { presence: (view) => store.publishPresence(view) },
|
||||
_stampNavigation() {},
|
||||
getAircraftTrackingTarget: () =>
|
||||
tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null,
|
||||
},
|
||||
readDataManager: () => dataManager,
|
||||
readContext: () => context,
|
||||
readActiveStyle: () => 'normal',
|
||||
readUtilityDisclosureOpen: () => false,
|
||||
enterPanels() {},
|
||||
exitPanels() {},
|
||||
});
|
||||
const cockpit = coordinator.cockpitView;
|
||||
// The HUD, briefing and signals have their own tests.
|
||||
Object.assign(cockpit, {
|
||||
updateHud() {},
|
||||
setVisionMode() {},
|
||||
showBriefPage() {},
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
pushCockpitSignal() {},
|
||||
scheduleContextLayout() {},
|
||||
clearPredictiveRoute() {},
|
||||
});
|
||||
t.after(() => {
|
||||
coordinator.stop();
|
||||
app.unmount();
|
||||
document.body.classList.remove('cockpit-mode');
|
||||
});
|
||||
/** Turn a layer off and tell the Context owner, as the data manager does. */
|
||||
const disable = (layerId) => {
|
||||
layers.delete(layerId);
|
||||
_handleContextLayerChange.call(
|
||||
{
|
||||
cockpitView: cockpit,
|
||||
_dataManager: dataManager,
|
||||
_contextMode: null,
|
||||
_contextModeChanging: false,
|
||||
_contextSessionSnapshot: null,
|
||||
_contextRestoreState: null,
|
||||
_syncContextModeButtons() {},
|
||||
},
|
||||
{ type: 'visibility', layerId, enabled: false, origin: 'user' },
|
||||
);
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return { $, coordinator, cockpit, store, layers, disable, pinia };
|
||||
}
|
||||
|
||||
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, {
|
||||
enabled: ['flights'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const state = coordinator.getCockpitState();
|
||||
assert.equal(state.entryAllowed, true);
|
||||
assert.equal(state.entryBlockedReason, null);
|
||||
assert.equal(cockpit.enter(), true);
|
||||
});
|
||||
|
||||
test('a military aircraft enters with Military Flights alone', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, {
|
||||
enabled: ['military'],
|
||||
tracked: { layerId: 'military', info: MILITARY },
|
||||
});
|
||||
assert.equal(coordinator.getCockpitState().entryAllowed, true);
|
||||
assert.equal(cockpit.enter(), true);
|
||||
});
|
||||
|
||||
test('a Context change in flight still blocks entry, and says so', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, {
|
||||
enabled: ['flights', 'military'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
contextMode: 'flights',
|
||||
changing: true,
|
||||
});
|
||||
const state = coordinator.getCockpitState();
|
||||
assert.equal(state.entryAllowed, false);
|
||||
assert.equal(state.entryBlockedReason, 'contacts-starting');
|
||||
assert.equal(cockpit.enter(), false);
|
||||
const voice = coordinator.controlCockpit('enter');
|
||||
assert.equal(voice.ok, false);
|
||||
assert.match(voice.error, /Context is still changing/);
|
||||
assert.equal(cockpit.active, false);
|
||||
});
|
||||
|
||||
test('with no aircraft layer on, entry is refused for that reason, never for Contacts', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t);
|
||||
const state = coordinator.getCockpitState();
|
||||
assert.equal(state.entryBlockedReason, 'subject-layer-off');
|
||||
const voice = coordinator.controlCockpit('enter');
|
||||
assert.equal(voice.ok, false);
|
||||
assert.match(voice.error, /No aircraft layer is on/);
|
||||
assert.doesNotMatch(voice.error, /contacts/i);
|
||||
assert.equal(voice.state.entryBlockedReason, 'subject-layer-off');
|
||||
assert.equal(cockpit.active, false);
|
||||
});
|
||||
|
||||
test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => {
|
||||
const { coordinator } = fixture(t, { enabled: ['military'] });
|
||||
assert.equal(
|
||||
coordinator.getCockpitState().entryBlockedReason,
|
||||
'no-tracked-aircraft',
|
||||
);
|
||||
});
|
||||
|
||||
test('voice enters without Contacts when the ridden layer is on', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, {
|
||||
enabled: ['flights'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const result = coordinator.controlCockpit('enter');
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.equal(cockpit.active, true);
|
||||
});
|
||||
|
||||
test('other layers may be turned off during a session; the ridden layer ends it', (t) => {
|
||||
const { cockpit, disable } = fixture(t, {
|
||||
enabled: ['flights', 'military', 'ais-live-vessels'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
assert.equal(cockpit.enter(), true);
|
||||
disable('military');
|
||||
disable('ais-live-vessels');
|
||||
assert.equal(cockpit.active, true, 'other feeds are free to go off');
|
||||
disable('flights');
|
||||
assert.equal(cockpit.active, false, 'the ridden layer going off ends it');
|
||||
});
|
||||
|
||||
test('the readout offers the same entry, labelled by kind, and enters on click', async (t) => {
|
||||
const { $, cockpit, pinia } = fixture(t, {
|
||||
enabled: ['flights'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
useHudStore(pinia);
|
||||
const readoutEntry = $('#cockpit-entry-readout');
|
||||
assert.equal(readoutEntry.hidden, true, 'hidden until entry is available');
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(readoutEntry.hidden, false);
|
||||
assert.match(readoutEntry.textContent, /flight/);
|
||||
assert.match(readoutEntry.textContent, /COCKPIT/);
|
||||
assert.equal(
|
||||
$('#cockpit-entry').hidden,
|
||||
false,
|
||||
'the Contacts entry follows the same presence',
|
||||
);
|
||||
readoutEntry.click();
|
||||
await nextTick();
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter');
|
||||
});
|
||||
@@ -1,5 +1,99 @@
|
||||
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../data/inputOwnership.js';
|
||||
|
||||
/** How far from the followed track a double-click may land, CSS pixels. */
|
||||
export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32;
|
||||
|
||||
/**
|
||||
* Whether a double-click landed on the followed track's mark.
|
||||
* @param {{x: number, y: number}|null} click Canvas position of the click.
|
||||
* @param {{x: number, y: number}|null|undefined} subject Canvas position of
|
||||
* the track, or nothing when it is off screen.
|
||||
* @param {number} [radiusPx]
|
||||
*/
|
||||
export function doubleClickHitsSubject(
|
||||
click,
|
||||
subject,
|
||||
radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX,
|
||||
) {
|
||||
if (!click || !subject) return false;
|
||||
const distance = Math.hypot(click.x - subject.x, click.y - subject.y);
|
||||
return Number.isFinite(distance) && distance <= radiusPx;
|
||||
}
|
||||
|
||||
/** Where the track `kind` would ride is drawn now, as a world position. */
|
||||
function subjectWorldPosition(kind) {
|
||||
if (kind !== 'aircraft') return null;
|
||||
return (
|
||||
this.viewer.trackedEntity?.position?.getValue?.(
|
||||
this.viewer.clock?.currentTime,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** The followed track's canvas position now, or null off screen. */
|
||||
function subjectCanvasPosition() {
|
||||
const kind = this.resolveEntryKind();
|
||||
const world = kind ? subjectWorldPosition.call(this, kind) : null;
|
||||
const scene = this.viewer.scene;
|
||||
if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function')
|
||||
return null;
|
||||
return (
|
||||
scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter first person when the followed track itself is double-clicked: the
|
||||
* same entry, and the same gates, as C and the entry buttons. A
|
||||
* double-click anywhere else, or one a tool owns (drawing ends a shape with
|
||||
* one), is left alone.
|
||||
* @param {{x: number, y: number}|null|undefined} position Canvas position.
|
||||
* @returns {boolean} True when it landed on the followed track, entered or
|
||||
* refused by the gate: the caller does nothing else with it.
|
||||
*/
|
||||
export function onSceneDoubleClick(position) {
|
||||
if (this.active || this.destroyed || !isPointerFree()) return false;
|
||||
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y))
|
||||
return false;
|
||||
if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this)))
|
||||
return false;
|
||||
this.enter();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the scene's double-click through the Cockpit first. Cesium's Viewer
|
||||
* installs its own (pickAndTrackObject), which untracks whenever the pick is
|
||||
* not an Entity: on a followed aircraft's model, a primitive, it would drop
|
||||
* the very track the double-click means to ride. A double-click on the
|
||||
* followed track is the Cockpit's; any other goes on to Cesium's.
|
||||
* @returns {() => void} Puts Cesium's handler back, unless another owner
|
||||
* (the draw tool borrows it) has replaced ours since.
|
||||
*/
|
||||
export function routeSceneDoubleClick() {
|
||||
const handler = this.viewer.screenSpaceEventHandler;
|
||||
if (typeof handler?.setInputAction !== 'function') return () => {};
|
||||
const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
|
||||
const stock = handler.getInputAction?.(type) ?? null;
|
||||
const routed = (event) => {
|
||||
if (this.onSceneDoubleClick(event?.position)) return;
|
||||
stock?.(event);
|
||||
};
|
||||
handler.setInputAction(routed, type);
|
||||
return () => {
|
||||
if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed)
|
||||
return;
|
||||
if (stock) handler.setInputAction(stock, type);
|
||||
else handler.removeInputAction(type);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cockpit's capture-phase keydown: Escape and C.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
export function onKeyDown(event) {
|
||||
if (this.destroyed) return false;
|
||||
if (event.repeat || event.isComposing) return;
|
||||
|
||||
@@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) {
|
||||
const degrees = roundHeadingDegrees(value);
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cockpit entry for each subject it can ride: an aircraft's cockpit.
|
||||
* Both entry buttons (Contacts and the Intel HUD readout) show it.
|
||||
*/
|
||||
const COCKPIT_ENTRY_FACES = Object.freeze({
|
||||
aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }),
|
||||
});
|
||||
|
||||
/**
|
||||
* The entry's glyph and localized label for the subject it would ride.
|
||||
* @param {'aircraft'|null|undefined} kind
|
||||
* @returns {{icon: string, label: string}}
|
||||
*/
|
||||
export function cockpitEntryFace(kind) {
|
||||
const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft;
|
||||
return { icon: face.icon, label: t(face.label) };
|
||||
}
|
||||
+54
-11
@@ -62,7 +62,31 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
|
||||
this.publishCockpitSignals();
|
||||
}
|
||||
|
||||
export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
/**
|
||||
* Take a status row off the list, as when the feed it warned about is back.
|
||||
* @param {string} key The row's key.
|
||||
*/
|
||||
export function dropCockpitSignal(key) {
|
||||
this.signalSignatures.delete(key);
|
||||
const kept = this.signalItems.filter((item) => item.key !== 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.
|
||||
* @param {object} snapshot `militaryAwareness.getContextSnapshot()` result.
|
||||
* @param {number} unknownCount Cohorts whose count is unknown.
|
||||
* @param {?{title: string, detail: string}} [partialPicture] The feed-off
|
||||
* row (see `readPartialPicture`), or null with every air feed on.
|
||||
*/
|
||||
export function updateCockpitSignals(
|
||||
snapshot,
|
||||
unknownCount,
|
||||
partialPicture = null,
|
||||
) {
|
||||
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
|
||||
const contacts = [];
|
||||
const subject = snapshot.subject;
|
||||
@@ -110,7 +134,9 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
}
|
||||
}
|
||||
contacts.sort((a, b) => a.distanceM - b.distanceM);
|
||||
const nextItems = contacts.slice(0, 5).map((item) => ({
|
||||
// A feed-off row leads, so the list never reads as the whole picture.
|
||||
const reserved = partialPicture ? 1 : 0;
|
||||
const nextItems = contacts.slice(0, 5 - reserved).map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
||||
@@ -120,18 +146,35 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
.filter((cohort) => cohort.count === null)
|
||||
.map((cohort) => cohort.source)
|
||||
.join(' · ');
|
||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
||||
key: 'input-status',
|
||||
nextItems.splice(
|
||||
4 - reserved,
|
||||
Math.max(0, nextItems.length - 4 + reserved),
|
||||
{
|
||||
key: 'input-status',
|
||||
tone: 'warning',
|
||||
title: tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN',
|
||||
'{count} INPUTS UNKNOWN',
|
||||
),
|
||||
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
|
||||
target: null,
|
||||
timestamp:
|
||||
previous.get('input-status')?.timestamp ||
|
||||
snapshot.evaluatedAt ||
|
||||
Date.now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (partialPicture) {
|
||||
nextItems.unshift({
|
||||
key: 'feed-status',
|
||||
tone: 'warning',
|
||||
title: tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN',
|
||||
'{count} INPUTS UNKNOWN',
|
||||
),
|
||||
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
|
||||
title: partialPicture.title,
|
||||
detail: partialPicture.detail,
|
||||
target: null,
|
||||
timestamp:
|
||||
previous.get('input-status')?.timestamp ||
|
||||
previous.get('feed-status')?.timestamp ||
|
||||
snapshot.evaluatedAt ||
|
||||
Date.now(),
|
||||
});
|
||||
|
||||
@@ -17,6 +17,30 @@ export function readAircraftInfo() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer the active view rides: the aircraft's own layer. It is read from
|
||||
* the tracked entity's id once the layer has dropped its tracking, so a layer
|
||||
* turned off still names itself.
|
||||
* @returns {'flights'|'military'|null}
|
||||
*/
|
||||
export function readSubjectLayerId() {
|
||||
if (!this.active) return null;
|
||||
const layerId = this.readAircraftInfo()?.layerId;
|
||||
if (layerId) return layerId;
|
||||
const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':');
|
||||
return ['flights', 'military'].includes(prefix) ? prefix : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject entry would take: a Cesium-tracked aircraft.
|
||||
* @returns {'aircraft'|null}
|
||||
*/
|
||||
export function resolveEntryKind() {
|
||||
return this.readAircraftInfo() && this.viewer.trackedEntity?.position
|
||||
? 'aircraft'
|
||||
: null;
|
||||
}
|
||||
|
||||
export function dispatchCockpitModeChanged(active, info = null) {
|
||||
const subjectId = active
|
||||
? String(info?.icao24 || '')
|
||||
@@ -247,9 +271,12 @@ export function exit({ restoreTracking = true } = {}) {
|
||||
this.restoreTrackingFrame(entity);
|
||||
}
|
||||
this.syncEntry();
|
||||
// Back to whichever entry was used: the Contacts button or the readout's.
|
||||
const readoutEntry = document.getElementById('cockpit-entry-readout');
|
||||
const restoreTarget =
|
||||
this.entryFocusOrigin === this.entry
|
||||
? this.entry
|
||||
this.entryFocusOrigin === this.entry ||
|
||||
(readoutEntry && this.entryFocusOrigin === readoutEntry)
|
||||
? this.entryFocusOrigin
|
||||
: this.entry || this.entryFocusOrigin;
|
||||
this.entryFocusOrigin = null;
|
||||
// The entry shows when Vue flushes the presence; read it after.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
SPACE_MISSIONS_LAYER_BLOCK_REASON,
|
||||
cockpitEntryAllowed,
|
||||
isExplicitUserIntentOrigin,
|
||||
parseContextLayerBlockReason,
|
||||
recordContextRestoreExplicitChange,
|
||||
@@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.cockpitView?.active &&
|
||||
!cockpitEntryAllowed({
|
||||
contextMode: this._contextMode,
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||
})
|
||||
) {
|
||||
// Turning off the layer the view rides ends it; every other layer may be
|
||||
// turned on or off freely during a session.
|
||||
const riddenLayerId = this.cockpitView?.active
|
||||
? this.cockpitView.readSubjectLayerId?.()
|
||||
: null;
|
||||
if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
|
||||
@@ -18,7 +18,10 @@ export async function _selectContextMode(
|
||||
this._contextModeChanging = true;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
if (mode !== 'flights' && this.cockpitView?.active) {
|
||||
// Cockpit does not need Contacts, so leaving it keeps the view; the
|
||||
// restore below ends it only by turning off the layer it rides. Another
|
||||
// mode takes the camera, so switching to one ends it.
|
||||
if (mode && mode !== 'flights' && this.cockpitView?.active) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
if (!mode) {
|
||||
@@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this.cockpitView?.active)
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
// A layer turned off ends Contacts but not the Cockpit: the view ends only
|
||||
// when the layer it rides goes off (see _handleContextLayerChange).
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
|
||||
@@ -33,6 +33,36 @@
|
||||
color: #00dcff;
|
||||
font-size: 18px;
|
||||
}
|
||||
/* The same entry under the selected vehicle's Intel HUD readout. The HUD
|
||||
lets clicks through, so the button takes them back. */
|
||||
#intel-hud #cockpit-entry-readout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--hud-border);
|
||||
border-radius: 6px;
|
||||
background: rgba(2, 12, 18, 0.72);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
#intel-hud #cockpit-entry-readout[hidden] {
|
||||
display: none;
|
||||
}
|
||||
#intel-hud #cockpit-entry-readout:hover,
|
||||
#intel-hud #cockpit-entry-readout:focus-visible {
|
||||
border-color: var(--hud-color);
|
||||
outline: none;
|
||||
}
|
||||
#intel-hud #cockpit-entry-readout .material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-size: 16px;
|
||||
text-transform: none;
|
||||
}
|
||||
#cockpit-entry kbd {
|
||||
padding: 3px 5px;
|
||||
border: 1px solid rgba(220, 238, 255, 0.25);
|
||||
|
||||
@@ -14,11 +14,14 @@ import MissionDetail from './panels/MissionDetail.vue';
|
||||
import MissionRoster from './panels/MissionRoster.vue';
|
||||
import RadioPanel from './panels/RadioPanel.vue';
|
||||
import UiButton from '../kit/UiButton.vue';
|
||||
import { cockpitEntryFace } from '../cockpitPresentation.js';
|
||||
|
||||
// ContextControls presents the mode switch and the nearby sites search and
|
||||
// publishes them; until it does, the panel shows its initial markup.
|
||||
const { view: ctx } = storeToRefs(useContextStore());
|
||||
const { presence: cockpit } = storeToRefs(useCockpitStore());
|
||||
// The entry rides the tracked aircraft (cockpitEntryFace).
|
||||
const entry = computed(() => cockpitEntryFace('aircraft'));
|
||||
// While the Bhote Koshi event is on, its panel is the rail's only content.
|
||||
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
|
||||
// The Scene runtime chip SceneControls presents while a scene plays.
|
||||
@@ -56,8 +59,8 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
|
||||
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
|
||||
<div class="global-context-actions" :aria-label="t('Contact Context actions')">
|
||||
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
||||
<span>{{ t('COCKPIT') }}</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
|
||||
<span>{{ entry.label }}</span>
|
||||
</button>
|
||||
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)">{{ t('SEARCH NEARBY SITES') }}</button>
|
||||
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" :aria-label="t('Reclassify tracked contact as TR-3B')" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')">🛸</UiButton>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useHudStore } from '../stores/hud.js';
|
||||
import { useCockpitStore } from '../stores/cockpit.js';
|
||||
import { cockpitEntryFace } from '../cockpitPresentation.js';
|
||||
|
||||
// The readouts, visibility, layout variant and colors IntelHUD publishes.
|
||||
const { hud } = storeToRefs(useHudStore());
|
||||
@@ -10,6 +12,12 @@ const { hud } = storeToRefs(useHudStore());
|
||||
// right after IntelHUD is built.
|
||||
const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null });
|
||||
const view = computed(() => hud.value ?? EMPTY);
|
||||
// The selected vehicle's readout offers the same entry as the Contacts
|
||||
// button, so first person needs no Contacts panel. The Cockpit controller
|
||||
// answers its clicks.
|
||||
const { presence: cockpit } = storeToRefs(useCockpitStore());
|
||||
// The entry rides the tracked aircraft (cockpitEntryFace).
|
||||
const entry = computed(() => cockpitEntryFace('aircraft'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,6 +54,10 @@ const view = computed(() => hud.value ?? EMPTY);
|
||||
<div class="hud-readout"><div id="hud-gsd">{{ view.gsd }}</div></div>
|
||||
<div class="hud-readout"><div id="hud-alt">{{ view.alt }}</div></div>
|
||||
<div class="hud-readout"><div id="hud-ais-vessel" :class="['hud-ais-vessel', { active: view.vessel.active }]">{{ view.vessel.text }}</div></div>
|
||||
<button id="cockpit-entry-readout" type="button" :hidden="!cockpit?.entryAvailable">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
|
||||
<span>{{ entry.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hud-bracket">┘</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
|
||||
// area scopes and the osm-places layer; natural-region candidates and the
|
||||
// tightened area tool wording follow review.
|
||||
'd0c5ad733670972713a54cb10676c42b7c6e78834bfff52b473c0e8a45e742c3',
|
||||
// control_cockpit states the own-layer entry rule (Contacts not required).
|
||||
'9522ff6f6d640bede81c51cea6b9601439b08becc5bc20b7355aec24feb17e8d',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user