mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge remote-tracking branch 'origin/main' into pr/wildlife-sightings
This commit is contained in:
commit
bd99000433
44 files changed
+1420
-139
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
|
||||||
|
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).
|
||||||
+16
-7
@@ -4949,7 +4949,7 @@ cannot claim full acceptance.
|
|||||||
|
|
||||||
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
|
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
|
||||||
prior state on exit; Space Missions refuses enablement while isolating replay
|
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
|
The proxy fetches registered URLs only, validates redirect origins, bounds
|
||||||
responses and decoding, caches successful snapshots, and backs off after failed
|
responses and decoding, caches successful snapshots, and backs off after failed
|
||||||
refreshes. A 304 confirms contact without appending history. Missing vehicles
|
refreshes. A 304 confirms contact without appending history. Missing vehicles
|
||||||
@@ -6172,12 +6172,21 @@ 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).
|
- 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 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.
|
- 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
|
- Cockpit entry needs only the layer of the aircraft it rides and that aircraft
|
||||||
Context chooser must be in Contacts mode, Live Flights and Military Flights must both
|
tracked: Live Flights for a civilian, Military Flights for a military aircraft
|
||||||
be enabled, and a civilian or military aircraft must be tracked. The visible
|
(the tracked info's `layerId`; `cockpitEntryAllowed` in
|
||||||
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone
|
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
|
||||||
flight-layer selection cannot enter a context-dependent cockpit.
|
layer, Live AIS Vessels and every other layer may be turned on or off before
|
||||||
- **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.
|
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 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 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.
|
- **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/CockpitBriefing.vue",
|
||||||
"src/ui/views/panels/CockpitContext.vue",
|
"src/ui/views/panels/CockpitContext.vue",
|
||||||
"src/ui/views/panels/CockpitRoute.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/CockpitRollingValue.vue",
|
||||||
"src/ui/views/panels/CockpitSignalList.vue",
|
"src/ui/views/panels/CockpitSignalList.vue",
|
||||||
"src/ui/views/panels/CockpitUtilityControls.vue",
|
"src/ui/views/panels/CockpitUtilityControls.vue",
|
||||||
@@ -885,6 +888,7 @@
|
|||||||
"src/ui/cockpitDisplayPortal.js",
|
"src/ui/cockpitDisplayPortal.js",
|
||||||
"src/ui/displayPanelScroll.js",
|
"src/ui/displayPanelScroll.js",
|
||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
|
"src/data/inputOwnership.js",
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
@@ -1013,6 +1017,7 @@
|
|||||||
"src/ui/cockpitDisplayPortal.js",
|
"src/ui/cockpitDisplayPortal.js",
|
||||||
"src/ui/displayPanelScroll.js",
|
"src/ui/displayPanelScroll.js",
|
||||||
"src/ui/cockpitInput.js",
|
"src/ui/cockpitInput.js",
|
||||||
|
"src/data/inputOwnership.js",
|
||||||
"src/ui/cockpitInstruments.js",
|
"src/ui/cockpitInstruments.js",
|
||||||
"src/ui/cockpitLayout.js",
|
"src/ui/cockpitLayout.js",
|
||||||
"src/ui/cockpitPresentation.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)}`,
|
`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
|
// (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
|
||||||
// reason rather than produce the half-entered state the operator saw: a
|
// With Contacts OFF, status must explain a shut gate without blaming
|
||||||
// plane anchored under the camera with no HUD and no exit control.
|
// 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' });
|
r = await run('control_cockpit', { action: 'status' });
|
||||||
const gateReport = r?.state || {};
|
const gateReport = r?.state || {};
|
||||||
const cockpitBeforeGate = await page.evaluate(() =>
|
const cockpitBeforeGate = await page.evaluate(() =>
|
||||||
@@ -2331,10 +2333,13 @@ async function runBehaviorLayer() {
|
|||||||
);
|
);
|
||||||
report(
|
report(
|
||||||
gateReport.active === false &&
|
gateReport.active === false &&
|
||||||
gateReport.entryAllowed === false &&
|
(gateReport.entryAllowed === true
|
||||||
gateReport.entryBlockedReason === 'contacts-inactive' &&
|
? gateReport.entryBlockedReason === null
|
||||||
|
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
|
||||||
|
gateReport.entryBlockedReason,
|
||||||
|
)) &&
|
||||||
!cockpitBeforeGate,
|
!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)}`,
|
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2348,12 +2353,16 @@ async function runBehaviorLayer() {
|
|||||||
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
|
||||||
);
|
);
|
||||||
report(
|
report(
|
||||||
gated?.ok === false &&
|
gated?.ok === true
|
||||||
/contacts/i.test(gated?.error || '') &&
|
? cockpitAfterGate
|
||||||
!cockpitAfterGate,
|
: !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
|
||||||
'behavior: cockpit entry with Contacts off is refused, not half-entered',
|
'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}`,
|
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
|
||||||
);
|
);
|
||||||
|
if (cockpitAfterGate)
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
|
||||||
|
);
|
||||||
|
|
||||||
await runBundledOutlineScenarios({
|
await runBundledOutlineScenarios({
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = {
|
|||||||
},
|
},
|
||||||
control_cockpit: {
|
control_cockpit: {
|
||||||
description:
|
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,
|
$position: 1,
|
||||||
parameters: {
|
parameters: {
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
|
|||||||
ui.indexOf("if (normalized === 'exit') {"),
|
ui.indexOf("if (normalized === 'exit') {"),
|
||||||
);
|
);
|
||||||
assert.ok(branch, 'cockpit enter branch is missing');
|
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 retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
|
||||||
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
gateIndex >= 0,
|
gateIndex >= 0,
|
||||||
'entry consults the same gate the manual chip uses',
|
'entry refuses on the gate it can check before tracking',
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
retargetIndex > gateIndex,
|
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 === 'enter') {"),
|
||||||
ui.indexOf("if (normalized === 'exit') {"),
|
ui.indexOf("if (normalized === 'exit') {"),
|
||||||
);
|
);
|
||||||
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
|
// Contacts is not required: only a Context change in flight or no aircraft
|
||||||
assert.match(branch, /Contacts must be active to enter Cockpit/);
|
// layer to ride shuts the gate before tracking, and the entry itself then
|
||||||
assert.match(branch, /Contacts is still starting up/);
|
// 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', () => {
|
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, /entryBlockedReason:/, 'and a refusal can be explained');
|
||||||
assert.match(state, /'contacts-starting'/);
|
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'/);
|
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) => {
|
test('rows render the signals the controller presents', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
await render(f, [
|
await render(f, [
|
||||||
|
|||||||
+23
-16
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
|
|||||||
return restored;
|
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
|
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
|
||||||
* aircraft alone is not sufficient: both observed-flight feeds must still be
|
* (Live Flights for a civilian, Military Flights for a military aircraft) must
|
||||||
* active so the cockpit's surrounding-contact picture is not presented as a
|
* be on. Contacts and the other feeds are not required. When a source the
|
||||||
* complete context view when one source is disabled.
|
* 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 {object} input Current subject and Context state.
|
||||||
* @param {string|null} input.contextMode Active Context mode.
|
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
|
||||||
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
|
* ('flights' or 'military'), or null with none tracked.
|
||||||
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
|
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
|
||||||
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
|
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
|
||||||
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
* @returns {boolean} Whether cockpit entry may be offered or activated.
|
||||||
*/
|
*/
|
||||||
export function cockpitEntryAllowed({
|
export function cockpitEntryAllowed({
|
||||||
contextMode,
|
subjectLayerId,
|
||||||
|
subjectLayerEnabled,
|
||||||
contextModeChanging,
|
contextModeChanging,
|
||||||
flightsEnabled,
|
|
||||||
militaryEnabled,
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
contextMode === 'flights' &&
|
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
|
||||||
!contextModeChanging &&
|
Boolean(subjectLayerEnabled) &&
|
||||||
Boolean(flightsEnabled) &&
|
!contextModeChanging
|
||||||
Boolean(militaryEnabled)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
assert.equal(cockpitEntryAllowed({
|
||||||
contextMode: 'flights',
|
subjectLayerId: 'flights',
|
||||||
|
subjectLayerEnabled: true,
|
||||||
contextModeChanging: false,
|
contextModeChanging: false,
|
||||||
flightsEnabled: true,
|
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
|
||||||
militaryEnabled: true,
|
|
||||||
}), true);
|
|
||||||
assert.equal(cockpitEntryAllowed({
|
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,
|
contextModeChanging: true,
|
||||||
flightsEnabled: true,
|
}), false, 'Cockpit remains unavailable until a Context mode change settles');
|
||||||
militaryEnabled: true,
|
|
||||||
}), false, 'Cockpit remains unavailable until Contacts activation settles');
|
|
||||||
assert.equal(cockpitEntryAllowed({
|
assert.equal(cockpitEntryAllowed({
|
||||||
contextMode: null,
|
subjectLayerId: 'military',
|
||||||
|
subjectLayerEnabled: false,
|
||||||
contextModeChanging: false,
|
contextModeChanging: false,
|
||||||
flightsEnabled: true,
|
}), false, "the ridden aircraft's own layer is required");
|
||||||
militaryEnabled: true,
|
|
||||||
}), false, 'ordinary layer selection is not an operational Contacts context');
|
|
||||||
assert.equal(cockpitEntryAllowed({
|
assert.equal(cockpitEntryAllowed({
|
||||||
contextMode: 'flights',
|
subjectLayerId: null,
|
||||||
|
subjectLayerEnabled: false,
|
||||||
contextModeChanging: false,
|
contextModeChanging: false,
|
||||||
flightsEnabled: true,
|
}), false, 'there is no aircraft to ride');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('context teardown guard restores coordination state after success and failure', async () => {
|
test('context teardown guard restores coordination state after success and failure', async () => {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
|
|||||||
// status its module never emits guards nothing. See the settled-but-fetching
|
// status its module never emits guards nothing. See the settled-but-fetching
|
||||||
// tests below, which use militaryInstallations' genuine first-fetch readout.
|
// tests below, which use militaryInstallations' genuine first-fetch readout.
|
||||||
layerStats = {},
|
layerStats = {},
|
||||||
|
subscribe = undefined,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
const originalDocument = globalThis.document;
|
const originalDocument = globalThis.document;
|
||||||
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
|
|||||||
setEnabled,
|
setEnabled,
|
||||||
getLayerLifecycleState,
|
getLayerLifecycleState,
|
||||||
layers,
|
layers,
|
||||||
|
...(subscribe ? { subscribe } : {}),
|
||||||
});
|
});
|
||||||
militaryAwarenessLayer.setParams({ passive: true });
|
militaryAwarenessLayer.setParams({ passive: true });
|
||||||
militaryAwarenessLayer.init(viewer);
|
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 () => {
|
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let releaseAircraft;
|
let releaseAircraft;
|
||||||
|
|||||||
@@ -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
|
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
|
||||||
// Pistes extend the generated layer enums and alias hint; voice geometry
|
// Pistes extend the generated layer enums and alias hint; voice geometry
|
||||||
// adds the analyst area scopes and the osm-places layer.
|
// 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(
|
assert.equal(
|
||||||
crypto.createHash('sha256').update(block).digest('hex'),
|
crypto.createHash('sha256').update(block).digest('hex'),
|
||||||
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
|
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
|
||||||
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
|
||||||
);
|
);
|
||||||
const instructions = fs.readFileSync(
|
const instructions = fs.readFileSync(
|
||||||
|
|||||||
@@ -2187,6 +2187,9 @@
|
|||||||
"{value} KM/H": "{value} كم/س",
|
"{value} KM/H": "{value} كم/س",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
|
"{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}",
|
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||||
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
|
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
|
||||||
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
|
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
|
||||||
|
|||||||
@@ -2087,6 +2087,9 @@
|
|||||||
"{value} KM/H": "{value} KM/H",
|
"{value} KM/H": "{value} KM/H",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
|
"{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}",
|
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
|
||||||
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
|
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
|
||||||
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
|
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
|
||||||
|
|||||||
@@ -1920,6 +1920,9 @@
|
|||||||
"N/A": "N/D",
|
"N/A": "N/D",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
|
"{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",
|
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
|
||||||
"ft": "ft",
|
"ft": "ft",
|
||||||
"kts": "kn",
|
"kts": "kn",
|
||||||
|
|||||||
@@ -1660,6 +1660,9 @@
|
|||||||
"REGIONAL NEWS": "ACTUS RÉGIONALES",
|
"REGIONAL NEWS": "ACTUS RÉGIONALES",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
|
"{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",
|
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
|
"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",
|
"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",
|
"REGIONAL NEWS": "NOTIZIE REGIONALI",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
|
"{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",
|
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
|
"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",
|
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
|
||||||
|
|||||||
@@ -1612,6 +1612,9 @@
|
|||||||
"REGIONAL NEWS": "地域ニュース",
|
"REGIONAL NEWS": "地域ニュース",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
|
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
|
||||||
|
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
|
||||||
|
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
|
||||||
|
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
|
||||||
"CONTEXT STANDBY": "コンテキスト待機",
|
"CONTEXT STANDBY": "コンテキスト待機",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
|
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
|
||||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
|
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
|
||||||
@@ -1684,7 +1687,7 @@
|
|||||||
"Restore real aircraft": "実際の航空機に戻す",
|
"Restore real aircraft": "実際の航空機に戻す",
|
||||||
"Reclassify as TR-3B": "TR-3Bに再分類",
|
"Reclassify as TR-3B": "TR-3Bに再分類",
|
||||||
"TRACK ACQUIRED": "追跡開始",
|
"TRACK ACQUIRED": "追跡開始",
|
||||||
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
|
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
|
||||||
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
|
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
|
||||||
"No selected data layers": "選択中のデータレイヤーはありません",
|
"No selected data layers": "選択中のデータレイヤーはありません",
|
||||||
"{count} data layers could not be cleared": {
|
"{count} data layers could not be cleared": {
|
||||||
|
|||||||
@@ -1660,6 +1660,9 @@
|
|||||||
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
|
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
|
"{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",
|
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
|
"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",
|
"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",
|
"Restore real aircraft": "Restaurar a aeronave real",
|
||||||
"Reclassify as TR-3B": "Reclassificar como TR-3B",
|
"Reclassify as TR-3B": "Reclassificar como TR-3B",
|
||||||
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
|
"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",
|
"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",
|
"No selected data layers": "Nenhuma camada de dados selecionada",
|
||||||
"{count} data layers could not be cleared": {
|
"{count} data layers could not be cleared": {
|
||||||
|
|||||||
@@ -458,7 +458,7 @@
|
|||||||
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
|
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
|
||||||
"Select flight {title}": "Выбрать рейс {title}",
|
"Select flight {title}": "Выбрать рейс {title}",
|
||||||
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
|
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
|
||||||
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
|
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
|
||||||
"Restore real aircraft": "Вернуть реальный самолёт",
|
"Restore real aircraft": "Вернуть реальный самолёт",
|
||||||
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
|
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
|
||||||
"No selected data layers": "Нет выбранных слоёв данных",
|
"No selected data layers": "Нет выбранных слоёв данных",
|
||||||
@@ -1953,6 +1953,9 @@
|
|||||||
"N/A": "Н/Д",
|
"N/A": "Н/Д",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
|
"{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} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
|
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
|
||||||
"ft": "фт",
|
"ft": "фт",
|
||||||
"kts": "уз",
|
"kts": "уз",
|
||||||
|
|||||||
@@ -1683,6 +1683,9 @@
|
|||||||
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
|
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
|
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
|
||||||
|
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
|
||||||
|
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
|
||||||
|
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||||
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
|
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
|
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
|
||||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
|
||||||
@@ -1761,7 +1764,7 @@
|
|||||||
"Restore real aircraft": "Повернути справжній літак",
|
"Restore real aircraft": "Повернути справжній літак",
|
||||||
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
|
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
|
||||||
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
|
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
|
||||||
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
|
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
|
||||||
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
|
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
|
||||||
"No selected data layers": "Шари даних не вибрано",
|
"No selected data layers": "Шари даних не вибрано",
|
||||||
"{count} data layers could not be cleared": {
|
"{count} data layers could not be cleared": {
|
||||||
|
|||||||
@@ -1588,6 +1588,9 @@
|
|||||||
"REGIONAL NEWS": "地区新闻",
|
"REGIONAL NEWS": "地区新闻",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
|
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
|
||||||
|
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
|
||||||
|
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
|
||||||
|
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
|
||||||
"CONTEXT STANDBY": "态势待机",
|
"CONTEXT STANDBY": "态势待机",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
|
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
|
||||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
|
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
|
||||||
@@ -1660,7 +1663,7 @@
|
|||||||
"Restore real aircraft": "恢复真实飞机",
|
"Restore real aircraft": "恢复真实飞机",
|
||||||
"Reclassify as TR-3B": "重新归类为 TR-3B",
|
"Reclassify as TR-3B": "重新归类为 TR-3B",
|
||||||
"TRACK ACQUIRED": "已获取航迹",
|
"TRACK ACQUIRED": "已获取航迹",
|
||||||
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
|
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
|
||||||
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
|
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
|
||||||
"No selected data layers": "没有已选数据图层",
|
"No selected data layers": "没有已选数据图层",
|
||||||
"{count} data layers could not be cleared": {
|
"{count} data layers could not be cleared": {
|
||||||
|
|||||||
@@ -1578,6 +1578,9 @@
|
|||||||
"REGIONAL NEWS": "地區新聞",
|
"REGIONAL NEWS": "地區新聞",
|
||||||
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
|
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
|
||||||
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
|
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
|
||||||
|
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
|
||||||
|
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
|
||||||
|
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
|
||||||
"CONTEXT STANDBY": "態勢待機",
|
"CONTEXT STANDBY": "態勢待機",
|
||||||
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
|
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
|
||||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
|
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
|
||||||
@@ -1650,7 +1653,7 @@
|
|||||||
"Restore real aircraft": "恢復真實飛機",
|
"Restore real aircraft": "恢復真實飛機",
|
||||||
"Reclassify as TR-3B": "重新歸類為 TR-3B",
|
"Reclassify as TR-3B": "重新歸類為 TR-3B",
|
||||||
"TRACK ACQUIRED": "已獲取航跡",
|
"TRACK ACQUIRED": "已獲取航跡",
|
||||||
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
|
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
|
||||||
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
|
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
|
||||||
"No selected data layers": "沒有已選數據圖層",
|
"No selected data layers": "沒有已選數據圖層",
|
||||||
"{count} data layers could not be cleared": {
|
"{count} data layers could not be cleared": {
|
||||||
|
|||||||
Vendored
+1
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
|||||||
|
|
||||||
attachDataManager(dataManager) {
|
attachDataManager(dataManager) {
|
||||||
layerState.dataManager = dataManager;
|
layerState.dataManager = dataManager;
|
||||||
|
parts.dependencies.watchAdoptions(dataManager);
|
||||||
},
|
},
|
||||||
|
|
||||||
setParams(params = {}) {
|
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() {
|
function activateOperationalContext() {
|
||||||
const activationId = ++layerState.activationId;
|
const activationId = ++layerState.activationId;
|
||||||
layerState.autoFocusRetryPending = false;
|
layerState.autoFocusRetryPending = false;
|
||||||
@@ -113,6 +132,7 @@ export function createDependencies({
|
|||||||
refreshAfterDeferredDependency,
|
refreshAfterDeferredDependency,
|
||||||
enableDependencies,
|
enableDependencies,
|
||||||
releaseOwnedDependencies,
|
releaseOwnedDependencies,
|
||||||
|
watchAdoptions,
|
||||||
activateOperationalContext,
|
activateOperationalContext,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -340,6 +340,7 @@ export function createLifecycle({
|
|||||||
layerState.clearListener = null;
|
layerState.clearListener = null;
|
||||||
layerState.subjectClearListener = null;
|
layerState.subjectClearListener = null;
|
||||||
layerState.viewer = null;
|
layerState.viewer = null;
|
||||||
|
parts.dependencies.watchAdoptions(null);
|
||||||
layerState.dataManager = null;
|
layerState.dataManager = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export function createState({ services }) {
|
|||||||
// layers activate only after Contacts is explicitly selected.
|
// layers activate only after Contacts is explicitly selected.
|
||||||
passive: true,
|
passive: true,
|
||||||
ownedDependencies: new Set(),
|
ownedDependencies: new Set(),
|
||||||
|
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||||
|
unwatchAdoptions: null,
|
||||||
subject: null,
|
subject: null,
|
||||||
results: null,
|
results: null,
|
||||||
visual: null,
|
visual: null,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
|
|||||||
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
|
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
|
||||||
const SPAN_TEXT =
|
const SPAN_TEXT =
|
||||||
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
|
/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. */
|
/** 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 TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
|
||||||
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
|
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
|
||||||
@@ -67,7 +70,24 @@ function referencedGlyphs() {
|
|||||||
};
|
};
|
||||||
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
|
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(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 statement = match[1].replaceAll('?.', '.');
|
||||||
const assigned = statement.includes('?')
|
const assigned = statement.includes('?')
|
||||||
? statement.slice(statement.indexOf('?') + 1)
|
? 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('; '),
|
||||||
|
);
|
||||||
|
});
|
||||||
+26
-5
@@ -19,6 +19,14 @@ import {
|
|||||||
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
||||||
} from './cockpitPresentation.js';
|
} from './cockpitPresentation.js';
|
||||||
|
|
||||||
|
/** The aircraft eye: just ahead of and above the reported position. */
|
||||||
|
const AIRCRAFT_CAMERA_PROFILE = Object.freeze({
|
||||||
|
forwardM: COCKPIT_FORWARD_OFFSET_M,
|
||||||
|
upM: COCKPIT_UP_OFFSET_M,
|
||||||
|
pitchDeg: COCKPIT_VIEW_PITCH_DEG,
|
||||||
|
minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M,
|
||||||
|
});
|
||||||
|
|
||||||
export function update() {
|
export function update() {
|
||||||
if (this.destroyed) return false;
|
if (this.destroyed) return false;
|
||||||
if (!this.active) {
|
if (!this.active) {
|
||||||
@@ -75,7 +83,20 @@ export function update() {
|
|||||||
COCKPIT_HEADING_SLEW_DPS * dtSec,
|
COCKPIT_HEADING_SLEW_DPS * dtSec,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place the camera for one update: advance the inertial anchor, clamp it and
|
||||||
|
* the eye against the rendered surface, and look along the slewed heading.
|
||||||
|
* @param {Cesium.Cartesian3} target Authoritative subject position.
|
||||||
|
* @param {object} info Subject info.
|
||||||
|
* @param {number} nowMs Frame time.
|
||||||
|
* @param {number} dtSec Seconds since the previous camera update.
|
||||||
|
* @param {{forwardM: number, upM: number, pitchDeg: number,
|
||||||
|
* minClearanceM: number}} profile Eye offsets for the subject kind.
|
||||||
|
*/
|
||||||
|
function placeCamera(target, info, nowMs, dtSec, profile) {
|
||||||
if (!this.cockpitAnchorValid) {
|
if (!this.cockpitAnchorValid) {
|
||||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||||
this.cockpitAnchorValid = true;
|
this.cockpitAnchorValid = true;
|
||||||
@@ -89,7 +110,7 @@ export function update() {
|
|||||||
// 15/30-second interpolation and per-frame cache without exposing its
|
// 15/30-second interpolation and per-frame cache without exposing its
|
||||||
// sample-boundary corrections to the camera.
|
// sample-boundary corrections to the camera.
|
||||||
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
|
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
|
||||||
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
|
const pitchRad = Cesium.Math.toRadians(profile.pitchDeg);
|
||||||
const speedMps = Number.isFinite(info.velocityMps)
|
const speedMps = Number.isFinite(info.velocityMps)
|
||||||
? Math.max(0, info.velocityMps)
|
? Math.max(0, info.velocityMps)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -248,7 +269,7 @@ export function update() {
|
|||||||
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
||||||
anchorCartographic.height,
|
anchorCartographic.height,
|
||||||
cockpitFloorM,
|
cockpitFloorM,
|
||||||
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
|
profile.minClearanceM - profile.upM,
|
||||||
);
|
);
|
||||||
if (minimumAnchorHeightM !== anchorCartographic.height) {
|
if (minimumAnchorHeightM !== anchorCartographic.height) {
|
||||||
anchorCartographic.height = minimumAnchorHeightM;
|
anchorCartographic.height = minimumAnchorHeightM;
|
||||||
@@ -297,7 +318,7 @@ export function update() {
|
|||||||
|
|
||||||
Cesium.Cartesian3.multiplyByScalar(
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
this.scratchHorizontal,
|
this.scratchHorizontal,
|
||||||
COCKPIT_FORWARD_OFFSET_M,
|
profile.forwardM,
|
||||||
this.scratchCamera,
|
this.scratchCamera,
|
||||||
);
|
);
|
||||||
Cesium.Cartesian3.add(
|
Cesium.Cartesian3.add(
|
||||||
@@ -309,7 +330,7 @@ export function update() {
|
|||||||
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
|
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
|
||||||
Cesium.Cartesian3.multiplyByScalar(
|
Cesium.Cartesian3.multiplyByScalar(
|
||||||
this.scratchTarget,
|
this.scratchTarget,
|
||||||
COCKPIT_UP_OFFSET_M,
|
profile.upM,
|
||||||
this.scratchTarget,
|
this.scratchTarget,
|
||||||
);
|
);
|
||||||
Cesium.Cartesian3.add(
|
Cesium.Cartesian3.add(
|
||||||
@@ -338,7 +359,7 @@ export function update() {
|
|||||||
const safeHeightM = cockpitGroundSafeHeight(
|
const safeHeightM = cockpitGroundSafeHeight(
|
||||||
cameraCartographic.height,
|
cameraCartographic.height,
|
||||||
cockpitFloorM,
|
cockpitFloorM,
|
||||||
COCKPIT_MIN_GROUND_CLEARANCE_M,
|
profile.minClearanceM,
|
||||||
);
|
);
|
||||||
if (safeHeightM !== cameraCartographic.height) {
|
if (safeHeightM !== cameraCartographic.height) {
|
||||||
cameraCartographic.height = safeHeightM;
|
cameraCartographic.height = safeHeightM;
|
||||||
|
|||||||
@@ -25,6 +25,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
|||||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
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. */
|
/** Hand a fresh copy of the Contact card to the view. */
|
||||||
export function publishContextView() {
|
export function publishContextView() {
|
||||||
const view = this.contextView;
|
const view = this.contextView;
|
||||||
@@ -53,7 +74,11 @@ export function updateContext(info, heading) {
|
|||||||
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
|
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
|
||||||
const trackedId = info.icao24 || info.id;
|
const trackedId = info.icao24 || info.id;
|
||||||
const readout = resolveCockpitContextReadout({ snapshot, info });
|
const readout = resolveCockpitContextReadout({ snapshot, info });
|
||||||
|
const partial = readPartialPicture.call(this);
|
||||||
if (!readout.visible) {
|
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.showContextCard(false);
|
||||||
this.contextLayoutStamp = null;
|
this.contextLayoutStamp = null;
|
||||||
this.pushCockpitSignal(
|
this.pushCockpitSignal(
|
||||||
@@ -62,6 +87,14 @@ export function updateContext(info, heading) {
|
|||||||
t('CONTEXT STANDBY'),
|
t('CONTEXT STANDBY'),
|
||||||
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
|
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
|
||||||
);
|
);
|
||||||
|
if (partial)
|
||||||
|
this.pushCockpitSignal(
|
||||||
|
'feed-status',
|
||||||
|
'warning',
|
||||||
|
partial.title,
|
||||||
|
partial.detail,
|
||||||
|
);
|
||||||
|
else this.dropCockpitSignal?.('feed-status');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,19 +206,22 @@ export function updateContext(info, heading) {
|
|||||||
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
view.uncertainty = unknownCount
|
// A feed that is off outranks unknown inputs: the picture is partial.
|
||||||
? tn(
|
view.uncertainty = partial
|
||||||
unknownCount,
|
? partial.title
|
||||||
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
: unknownCount
|
||||||
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
|
? tn(
|
||||||
)
|
unknownCount,
|
||||||
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
|
'{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)
|
view.updated = Number.isFinite(snapshot.evaluatedAt)
|
||||||
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
|
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
|
||||||
: '--:--:--Z';
|
: '--:--:--Z';
|
||||||
view.state = unknownCount ? 'uncertain' : 'current';
|
view.state = unknownCount || partial ? 'uncertain' : 'current';
|
||||||
this.publishContextView();
|
this.publishContextView();
|
||||||
this.updateCockpitSignals(snapshot, unknownCount);
|
this.updateCockpitSignals(snapshot, unknownCount, partial);
|
||||||
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
||||||
this.contextLayoutStamp = snapshot.evaluatedAt;
|
this.contextLayoutStamp = snapshot.evaluatedAt;
|
||||||
this.scheduleContextLayout();
|
this.scheduleContextLayout();
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import {
|
|||||||
updateContext,
|
updateContext,
|
||||||
} from './cockpitContext.js';
|
} from './cockpitContext.js';
|
||||||
import { createContextState } from './cockpitContextState.js';
|
import { createContextState } from './cockpitContextState.js';
|
||||||
|
import {
|
||||||
|
dropCockpitSignal,
|
||||||
|
dropContextSnapshotSignals,
|
||||||
|
publishCockpitSignals,
|
||||||
|
pushCockpitSignal,
|
||||||
|
updateCockpitSignals,
|
||||||
|
} from './cockpitSignals.js';
|
||||||
import { syncWeatherToggle } from './cockpitInstruments.js';
|
import { syncWeatherToggle } from './cockpitInstruments.js';
|
||||||
import { setContextCollapsed } from './cockpitLayout.js';
|
import { setContextCollapsed } from './cockpitLayout.js';
|
||||||
import { useCockpitStore } from './stores/cockpit.js';
|
import { useCockpitStore } from './stores/cockpit.js';
|
||||||
@@ -248,6 +255,119 @@ test('without a snapshot the card stands by, hidden', async (t) => {
|
|||||||
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
|
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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 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) => {
|
test('the weather toggle renders its state and the action it offers', async (t) => {
|
||||||
const { $, text, owner } = fixture(t);
|
const { $, text, owner } = fixture(t);
|
||||||
syncWeatherToggle.call(owner, true);
|
syncWeatherToggle.call(owner, true);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
|
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
|
||||||
import {
|
import {
|
||||||
readAircraftInfo,
|
readAircraftInfo,
|
||||||
|
readSubjectLayerId,
|
||||||
|
resolveEntryKind,
|
||||||
dispatchCockpitModeChanged,
|
dispatchCockpitModeChanged,
|
||||||
toggleTrackedTr3b,
|
toggleTrackedTr3b,
|
||||||
syncTr3bToggle,
|
syncTr3bToggle,
|
||||||
@@ -48,6 +50,8 @@ import {
|
|||||||
relocalizeBrief,
|
relocalizeBrief,
|
||||||
} from './cockpitBriefing.js';
|
} from './cockpitBriefing.js';
|
||||||
import {
|
import {
|
||||||
|
dropCockpitSignal,
|
||||||
|
dropContextSnapshotSignals,
|
||||||
handleSignalClick,
|
handleSignalClick,
|
||||||
publishCockpitSignals,
|
publishCockpitSignals,
|
||||||
pushCockpitSignal,
|
pushCockpitSignal,
|
||||||
@@ -60,7 +64,11 @@ import {
|
|||||||
syncContextLayout,
|
syncContextLayout,
|
||||||
syncSignalLayout,
|
syncSignalLayout,
|
||||||
} from './cockpitLayout.js';
|
} from './cockpitLayout.js';
|
||||||
import { onKeyDown } from './cockpitInput.js';
|
import {
|
||||||
|
onKeyDown,
|
||||||
|
onSceneDoubleClick,
|
||||||
|
routeSceneDoubleClick,
|
||||||
|
} from './cockpitInput.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
export class CockpitViewController {
|
export class CockpitViewController {
|
||||||
@@ -75,6 +83,7 @@ export class CockpitViewController {
|
|||||||
isUtilityDisclosureOpen = null,
|
isUtilityDisclosureOpen = null,
|
||||||
onEntered = null,
|
onEntered = null,
|
||||||
onExited = null,
|
onExited = null,
|
||||||
|
onRide = null,
|
||||||
restoreTrackingFrame = null,
|
restoreTrackingFrame = null,
|
||||||
publish = {},
|
publish = {},
|
||||||
} = {},
|
} = {},
|
||||||
@@ -114,6 +123,7 @@ export class CockpitViewController {
|
|||||||
this.onCameraTakeover = onCameraTakeover;
|
this.onCameraTakeover = onCameraTakeover;
|
||||||
this.onEntered = onEntered;
|
this.onEntered = onEntered;
|
||||||
this.onExited = onExited;
|
this.onExited = onExited;
|
||||||
|
this.onRide = onRide;
|
||||||
this.restoreTrackingFrame =
|
this.restoreTrackingFrame =
|
||||||
typeof restoreTrackingFrame === 'function'
|
typeof restoreTrackingFrame === 'function'
|
||||||
? restoreTrackingFrame
|
? restoreTrackingFrame
|
||||||
@@ -205,6 +215,13 @@ export class CockpitViewController {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
this._listen(this.entry, 'click', () => this.enter());
|
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.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
|
||||||
this._listen(this.mapViewButton, 'click', () => this.exit());
|
this._listen(this.mapViewButton, 'click', () => this.exit());
|
||||||
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
|
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
|
||||||
@@ -272,6 +289,12 @@ export class CockpitViewController {
|
|||||||
readAircraftInfo() {
|
readAircraftInfo() {
|
||||||
return readAircraftInfo.call(this);
|
return readAircraftInfo.call(this);
|
||||||
}
|
}
|
||||||
|
readSubjectLayerId() {
|
||||||
|
return readSubjectLayerId.call(this);
|
||||||
|
}
|
||||||
|
resolveEntryKind() {
|
||||||
|
return resolveEntryKind.call(this);
|
||||||
|
}
|
||||||
dispatchCockpitModeChanged(active, info) {
|
dispatchCockpitModeChanged(active, info) {
|
||||||
return dispatchCockpitModeChanged.call(this, active, info);
|
return dispatchCockpitModeChanged.call(this, active, info);
|
||||||
}
|
}
|
||||||
@@ -371,8 +394,19 @@ export class CockpitViewController {
|
|||||||
pushCockpitSignal(key, tone, title, detail, target) {
|
pushCockpitSignal(key, tone, title, detail, target) {
|
||||||
return pushCockpitSignal.call(this, key, tone, title, detail, target);
|
return pushCockpitSignal.call(this, key, tone, title, detail, target);
|
||||||
}
|
}
|
||||||
updateCockpitSignals(snapshot, unknownCount) {
|
dropCockpitSignal(key) {
|
||||||
return updateCockpitSignals.call(this, snapshot, unknownCount);
|
return dropCockpitSignal.call(this, key);
|
||||||
|
}
|
||||||
|
dropContextSnapshotSignals() {
|
||||||
|
return dropContextSnapshotSignals.call(this);
|
||||||
|
}
|
||||||
|
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
|
||||||
|
return updateCockpitSignals.call(
|
||||||
|
this,
|
||||||
|
snapshot,
|
||||||
|
unknownCount,
|
||||||
|
partialPicture,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
scheduleContextLayout() {
|
scheduleContextLayout() {
|
||||||
return scheduleContextLayout.call(this);
|
return scheduleContextLayout.call(this);
|
||||||
@@ -392,6 +426,9 @@ export class CockpitViewController {
|
|||||||
onKeyDown(event) {
|
onKeyDown(event) {
|
||||||
return onKeyDown.call(this, event);
|
return onKeyDown.call(this, event);
|
||||||
}
|
}
|
||||||
|
onSceneDoubleClick(position) {
|
||||||
|
return onSceneDoubleClick.call(this, position);
|
||||||
|
}
|
||||||
|
|
||||||
handleSignalClick(event) {
|
handleSignalClick(event) {
|
||||||
return handleSignalClick.call(this, event);
|
return handleSignalClick.call(this, event);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
|
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 { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
|
||||||
import { enterCockpitWithTracking } from '../cockpitTracking.js';
|
import { enterCockpitWithTracking } from '../cockpitTracking.js';
|
||||||
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
|
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
|
||||||
@@ -72,23 +75,29 @@ export class CockpitCoordinator {
|
|||||||
fetchRegionalBrief,
|
fetchRegionalBrief,
|
||||||
regionalDistanceM,
|
regionalDistanceM,
|
||||||
weatherCodeLabel,
|
weatherCodeLabel,
|
||||||
|
// The Contact card and Live Signals say which feeds are off.
|
||||||
|
isLayerEnabled: (layerId) => this._isLayerEnabled(layerId),
|
||||||
},
|
},
|
||||||
onVisionChange: (mode, active, options) =>
|
onVisionChange: (mode, active, options) =>
|
||||||
this._setCockpitVision(mode, active, options),
|
this._setCockpitVision(mode, active, options),
|
||||||
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
|
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
|
||||||
onCameraTakeover: () =>
|
onCameraTakeover: () =>
|
||||||
this._stampNavigation({ cancelPendingSelection: false }),
|
this._stampNavigation({ cancelPendingSelection: false }),
|
||||||
isEntryAllowed: () =>
|
// Only the tracked aircraft's own layer gates entry (see the policy).
|
||||||
cockpitEntryAllowed({
|
isEntryAllowed: () => {
|
||||||
contextMode: this._contextMode,
|
const subjectLayerId =
|
||||||
|
this.cockpitView?.readAircraftInfo?.()?.layerId ?? null;
|
||||||
|
return cockpitEntryAllowed({
|
||||||
|
subjectLayerId,
|
||||||
|
subjectLayerEnabled: this._isLayerEnabled(subjectLayerId),
|
||||||
contextModeChanging: this._contextModeChanging,
|
contextModeChanging: this._contextModeChanging,
|
||||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
});
|
||||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
},
|
||||||
}),
|
|
||||||
publish: this.publishCockpit,
|
publish: this.publishCockpit,
|
||||||
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
|
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
|
||||||
onEntered: () => this.enterPanels(),
|
onEntered: () => this.enterPanels(),
|
||||||
onExited: () => this.exitPanels(),
|
onExited: () => this.exitPanels(),
|
||||||
|
onRide: (layerId) => this._adoptRiddenLayer(layerId),
|
||||||
restoreTrackingFrame: (entity) => {
|
restoreTrackingFrame: (entity) => {
|
||||||
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
|
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
|
||||||
':',
|
':',
|
||||||
@@ -115,6 +124,44 @@ export class CockpitCoordinator {
|
|||||||
get activeStyle() {
|
get activeStyle() {
|
||||||
return this.readActiveStyle();
|
return this.readActiveStyle();
|
||||||
}
|
}
|
||||||
|
/** Whether a data layer is enabled; false for no layer. */
|
||||||
|
_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) =>
|
||||||
|
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() {
|
getCockpitState() {
|
||||||
const { militaryAwarenessLayer } = this.services;
|
const { militaryAwarenessLayer } = this.services;
|
||||||
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
|
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
|
||||||
@@ -129,19 +176,24 @@ export class CockpitCoordinator {
|
|||||||
const entryAllowed =
|
const entryAllowed =
|
||||||
!active &&
|
!active &&
|
||||||
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
|
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 {
|
return {
|
||||||
active,
|
active,
|
||||||
entryAllowed,
|
entryAllowed,
|
||||||
// Why entry is unavailable, so a refusal can be explained rather than
|
// 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:
|
entryBlockedReason:
|
||||||
entryAllowed || active
|
entryAllowed || active
|
||||||
? null
|
? null
|
||||||
: !gateOpen
|
: this._contextModeChanging
|
||||||
? this._contextModeChanging
|
? 'contacts-starting'
|
||||||
? 'contacts-starting'
|
: subjectLayerOff
|
||||||
: 'contacts-inactive'
|
? 'subject-layer-off'
|
||||||
: 'no-tracked-aircraft',
|
: 'no-tracked-aircraft',
|
||||||
visionMode: this.cockpitView?.visionMode || null,
|
visionMode: this.cockpitView?.visionMode || null,
|
||||||
subject: info
|
subject: info
|
||||||
? {
|
? {
|
||||||
@@ -260,17 +312,18 @@ export class CockpitCoordinator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (normalized === 'enter') {
|
if (normalized === 'enter') {
|
||||||
// Entry is gated exactly as the manual entry chip is. Attempting it while
|
// Attempting entry while the gate is shut produced the half-entered look
|
||||||
// the gate is shut produced the half-entered look the operator reported
|
// the operator reported (a plane anchored under the camera with no
|
||||||
// (a plane anchored under the camera with no cockpit around it), so
|
// cockpit around it), so refuse with the reason instead of trying. The
|
||||||
// refuse with the reason instead of trying.
|
// aircraft may not be tracked yet (a selection or a requested layer is
|
||||||
if (!this.cockpitView.isEntryAllowed?.()) {
|
// 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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
action: 'control_cockpit',
|
action: 'control_cockpit',
|
||||||
error: this._contextModeChanging
|
error: refusal,
|
||||||
? 'Contacts is still starting up — try Cockpit again in a moment'
|
|
||||||
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
|
|
||||||
state: this.getCockpitState(),
|
state: this.getCockpitState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
// 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, 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';
|
||||||
|
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,
|
||||||
|
dataManager: suppliedManager = null,
|
||||||
|
readContext = null,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
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 = 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 && dataManager.isEnabled(layerId)
|
||||||
|
? tracked.info
|
||||||
|
: null;
|
||||||
|
const entityFor = (subject) => ({
|
||||||
|
position: { getValue: () => null },
|
||||||
|
show: true,
|
||||||
|
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) => entities.has(candidate) },
|
||||||
|
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: 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' },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/** 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, retrack, 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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
@@ -1,5 +1,99 @@
|
|||||||
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
|
/** 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) {
|
export function onKeyDown(event) {
|
||||||
if (this.destroyed) return false;
|
if (this.destroyed) return false;
|
||||||
if (event.repeat || event.isComposing) return;
|
if (event.repeat || event.isComposing) return;
|
||||||
|
|||||||
@@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) {
|
|||||||
const degrees = roundHeadingDegrees(value);
|
const degrees = roundHeadingDegrees(value);
|
||||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
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) };
|
||||||
|
}
|
||||||
+75
-11
@@ -62,7 +62,52 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
|
|||||||
this.publishCockpitSignals();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
* @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 previous = new Map(this.signalItems.map((item) => [item.key, item]));
|
||||||
const contacts = [];
|
const contacts = [];
|
||||||
const subject = snapshot.subject;
|
const subject = snapshot.subject;
|
||||||
@@ -110,7 +155,9 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
contacts.sort((a, b) => a.distanceM - b.distanceM);
|
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,
|
...item,
|
||||||
timestamp:
|
timestamp:
|
||||||
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
||||||
@@ -120,18 +167,35 @@ export function updateCockpitSignals(snapshot, unknownCount) {
|
|||||||
.filter((cohort) => cohort.count === null)
|
.filter((cohort) => cohort.count === null)
|
||||||
.map((cohort) => cohort.source)
|
.map((cohort) => cohort.source)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
nextItems.splice(
|
||||||
key: 'input-status',
|
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',
|
tone: 'warning',
|
||||||
title: tn(
|
title: partialPicture.title,
|
||||||
unknownCount,
|
detail: partialPicture.detail,
|
||||||
'{count} INPUT UNKNOWN',
|
|
||||||
'{count} INPUTS UNKNOWN',
|
|
||||||
),
|
|
||||||
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
|
|
||||||
target: null,
|
target: null,
|
||||||
timestamp:
|
timestamp:
|
||||||
previous.get('input-status')?.timestamp ||
|
previous.get('feed-status')?.timestamp ||
|
||||||
snapshot.evaluatedAt ||
|
snapshot.evaluatedAt ||
|
||||||
Date.now(),
|
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) {
|
export function dispatchCockpitModeChanged(active, info = null) {
|
||||||
const subjectId = active
|
const subjectId = active
|
||||||
? String(info?.icao24 || '')
|
? String(info?.icao24 || '')
|
||||||
@@ -131,6 +155,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
|
|||||||
this.lastContextUpdateMs = 0;
|
this.lastContextUpdateMs = 0;
|
||||||
this.lastCameraUpdateMs = 0;
|
this.lastCameraUpdateMs = 0;
|
||||||
this.contextNavigationDeadlineMs = 0;
|
this.contextNavigationDeadlineMs = 0;
|
||||||
|
this.onRide?.(info.layerId);
|
||||||
this.dispatchCockpitModeChanged(true, info);
|
this.dispatchCockpitModeChanged(true, info);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -206,6 +231,7 @@ export function enter() {
|
|||||||
this.mapViewButton?.focus({ preventScroll: true });
|
this.mapViewButton?.focus({ preventScroll: true });
|
||||||
});
|
});
|
||||||
this.onEntered?.();
|
this.onEntered?.();
|
||||||
|
this.onRide?.(info.layerId);
|
||||||
this.dispatchCockpitModeChanged(true, info);
|
this.dispatchCockpitModeChanged(true, info);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -247,9 +273,12 @@ export function exit({ restoreTracking = true } = {}) {
|
|||||||
this.restoreTrackingFrame(entity);
|
this.restoreTrackingFrame(entity);
|
||||||
}
|
}
|
||||||
this.syncEntry();
|
this.syncEntry();
|
||||||
|
// Back to whichever entry was used: the Contacts button or the readout's.
|
||||||
|
const readoutEntry = document.getElementById('cockpit-entry-readout');
|
||||||
const restoreTarget =
|
const restoreTarget =
|
||||||
this.entryFocusOrigin === this.entry
|
this.entryFocusOrigin === this.entry ||
|
||||||
? this.entry
|
(readoutEntry && this.entryFocusOrigin === readoutEntry)
|
||||||
|
? this.entryFocusOrigin
|
||||||
: this.entry || this.entryFocusOrigin;
|
: this.entry || this.entryFocusOrigin;
|
||||||
this.entryFocusOrigin = null;
|
this.entryFocusOrigin = null;
|
||||||
// The entry shows when Vue flushes the presence; read it after.
|
// The entry shows when Vue flushes the presence; read it after.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { t, translateKnown } from '../i18n/index.js';
|
import { t, translateKnown } from '../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
SPACE_MISSIONS_LAYER_BLOCK_REASON,
|
SPACE_MISSIONS_LAYER_BLOCK_REASON,
|
||||||
cockpitEntryAllowed,
|
|
||||||
isExplicitUserIntentOrigin,
|
isExplicitUserIntentOrigin,
|
||||||
parseContextLayerBlockReason,
|
parseContextLayerBlockReason,
|
||||||
recordContextRestoreExplicitChange,
|
recordContextRestoreExplicitChange,
|
||||||
@@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
// Turning off the layer the view rides ends it; every other layer may be
|
||||||
this.cockpitView?.active &&
|
// turned on or off freely during a session.
|
||||||
!cockpitEntryAllowed({
|
const riddenLayerId = this.cockpitView?.active
|
||||||
contextMode: this._contextMode,
|
? this.cockpitView.readSubjectLayerId?.()
|
||||||
contextModeChanging: this._contextModeChanging,
|
: null;
|
||||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) {
|
||||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
this.cockpitView.exit({ restoreTracking: false });
|
this.cockpitView.exit({ restoreTracking: false });
|
||||||
}
|
}
|
||||||
this._syncContextModeButtons();
|
this._syncContextModeButtons();
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export async function _selectContextMode(
|
|||||||
this._contextModeChanging = true;
|
this._contextModeChanging = true;
|
||||||
this._syncContextModeButtons();
|
this._syncContextModeButtons();
|
||||||
try {
|
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 });
|
this.cockpitView.exit({ restoreTracking: false });
|
||||||
}
|
}
|
||||||
if (!mode) {
|
if (!mode) {
|
||||||
@@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({
|
|||||||
this._contextMode = null;
|
this._contextMode = null;
|
||||||
this._contextModeEntryIntent = null;
|
this._contextModeEntryIntent = null;
|
||||||
this._contextModeReplacementIntent = null;
|
this._contextModeReplacementIntent = null;
|
||||||
if (this.cockpitView?.active)
|
// A layer turned off ends Contacts but not the Cockpit: the view ends only
|
||||||
this.cockpitView.exit({ restoreTracking: false });
|
// when the layer it rides goes off (see _handleContextLayerChange).
|
||||||
this._syncContextModeButtons();
|
this._syncContextModeButtons();
|
||||||
try {
|
try {
|
||||||
await this._restoreContextSession({ notificationToken });
|
await this._restoreContextSession({ notificationToken });
|
||||||
|
|||||||
@@ -33,6 +33,36 @@
|
|||||||
color: #00dcff;
|
color: #00dcff;
|
||||||
font-size: 18px;
|
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 {
|
#cockpit-entry kbd {
|
||||||
padding: 3px 5px;
|
padding: 3px 5px;
|
||||||
border: 1px solid rgba(220, 238, 255, 0.25);
|
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 MissionRoster from './panels/MissionRoster.vue';
|
||||||
import RadioPanel from './panels/RadioPanel.vue';
|
import RadioPanel from './panels/RadioPanel.vue';
|
||||||
import UiButton from '../kit/UiButton.vue';
|
import UiButton from '../kit/UiButton.vue';
|
||||||
|
import { cockpitEntryFace } from '../cockpitPresentation.js';
|
||||||
|
|
||||||
// ContextControls presents the mode switch and the nearby sites search and
|
// ContextControls presents the mode switch and the nearby sites search and
|
||||||
// publishes them; until it does, the panel shows its initial markup.
|
// publishes them; until it does, the panel shows its initial markup.
|
||||||
const { view: ctx } = storeToRefs(useContextStore());
|
const { view: ctx } = storeToRefs(useContextStore());
|
||||||
const { presence: cockpit } = storeToRefs(useCockpitStore());
|
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.
|
// While the Bhote Koshi event is on, its panel is the rail's only content.
|
||||||
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
|
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
|
||||||
// The Scene runtime chip SceneControls presents while a scene plays.
|
// 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">
|
<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')">
|
<div class="global-context-actions" :aria-label="t('Contact Context actions')">
|
||||||
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
|
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
|
||||||
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
|
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
|
||||||
<span>{{ t('COCKPIT') }}</span>
|
<span>{{ entry.label }}</span>
|
||||||
</button>
|
</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>
|
<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>
|
<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 { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
import { useHudStore } from '../stores/hud.js';
|
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.
|
// The readouts, visibility, layout variant and colors IntelHUD publishes.
|
||||||
const { hud } = storeToRefs(useHudStore());
|
const { hud } = storeToRefs(useHudStore());
|
||||||
@@ -10,6 +12,12 @@ const { hud } = storeToRefs(useHudStore());
|
|||||||
// right after IntelHUD is built.
|
// right after IntelHUD is built.
|
||||||
const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null });
|
const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null });
|
||||||
const view = computed(() => hud.value ?? EMPTY);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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-gsd">{{ view.gsd }}</div></div>
|
||||||
<div class="hud-readout"><div id="hud-alt">{{ view.alt }}</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>
|
<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>
|
||||||
<div class="hud-bracket">┘</div>
|
<div class="hud-bracket">┘</div>
|
||||||
</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
|
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
|
||||||
// area scopes and the osm-places layer; natural-region candidates and the
|
// area scopes and the osm-places layer; natural-region candidates and the
|
||||||
// tightened area tool wording follow review.
|
// 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