feat(ui): render the Cockpit Contact card from a store

The Cockpit controller no longer writes the Contact card's scope,
navigation state, cohort counts, nearest contact, bearing, uncertainty,
update time or weather toggle: it keeps them as plain state, starting
from cockpitContextState.js, and publishes a copy to the gev-cockpit
store, and a new CockpitContext component renders it. A lost contact
still holds its last readout. The weather toggle flips from that state,
not the button's attribute. The card's visibility, collapse and layout
stay with the controller. The controller's publishBrief option becomes
a publish object, one entry per Cockpit view.
This commit is contained in:
Sameh Khamis committed 2026-10-31 14:08:19 -07:00
1 parent 82bbf16a8f
commit ffe14e50f5
14 files changed
+518 -183

No files matched your search

+11 -6
View File
@@ -128,12 +128,17 @@ They contain no application state.
backdrop to the end of the page from the first publish until the shell
is destroyed; the shell takes their clicks from the document), then
Cockpit (in progress: `CockpitBriefing` renders the briefing card's
pages, cycle toggle, regional news and local conditions from the
`gev-cockpit` store that the Cockpit controller publishes to, starting
from `cockpitBriefPages.js`, which both read; the controls keep the
controller's listeners, and the route card and signal list keep their
owners' bindings for now). Layers that write into the right rail
directly gain a slot instead.
pages, cycle toggle, regional news and local conditions, and
`CockpitContext` the Contact card's scope, navigation, counts, nearest
contact and weather toggle, from the `gev-cockpit` store that the Cockpit
controller publishes to, starting from `cockpitBriefPages.js` and
`cockpitContextState.js`, which both read; the controls keep the
controller's listeners, and the cards' visibility, collapse and layout
stay with the controller. The signal rows, the route card, the
instruments and the radio controls follow; the signal rows become a
component that keeps their focus-safe reorder, since a keyed Vue list
would move the focused row and drop focus several times a second).
Layers that write into the right rail directly gain a slot instead.
4. **Cleanup.** The build-time template expansion and the attribute
translation pass are retired once nothing uses them. Hosts register panels
+5
View File
@@ -28,7 +28,9 @@
"src/ui/views/panels/SdrCard.vue",
"src/ui/views/panels/PhoneTabBar.vue",
"src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js",
"src/ui/views/panels/MissionDetail.vue",
"src/ui/views/panels/MissionRoster.vue",
"src/ui/views/panels/AwarenessPanel.vue",
@@ -727,6 +729,7 @@
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -847,6 +850,7 @@
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -2548,6 +2552,7 @@
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitContextState.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
+3 -3
View File
@@ -268,7 +268,7 @@ test('the Contact panel never hides itself out from under its own NEXT button',
// A culled subject holds last-known content behind the CONTACT LOST cue.
assert.match(body, /readout\.contactLost/);
assert.match(body, /CONTACT LOST/);
assert.match(body, /this\.context\.dataset\.state = 'lost'/);
assert.match(body, /view\.state = 'lost'/);
assert.match(
body,
/if \(readout\.contactLost\) \{/,
@@ -276,14 +276,14 @@ test('the Contact panel never hides itself out from under its own NEXT button',
);
// PREVIOUS/NEXT must be written before any early return, so the operator can
// always step off the current contact — including a lost one.
const navIndex = body.indexOf('this.contextNext.disabled');
const navIndex = body.indexOf('view.canNext =');
const lostIndex = body.indexOf('if (readout.contactLost)');
assert.ok(
navIndex >= 0 && lostIndex > navIndex,
'nav state must be written before the CONTACT LOST return',
);
assert.equal(
(body.match(/this\.contextNext\.disabled/g) || []).length,
(body.match(/view\.canNext =/g) || []).length,
1,
'one owner for the NEXT enabled state, reached by every visible path',
);
+6 -2
View File
@@ -375,8 +375,12 @@ export class StyleManager extends ShellFacade {
_models3dBtn: this._models3dBtn,
},
operations: {
publishCockpitBrief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view),
publishCockpit: {
brief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view),
context: (view) =>
useCockpitStore(applicationPinia()).publishContext(view),
},
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args),
_stampNavigation: (...args) => this._stampNavigation(...args),
+77 -83
View File
@@ -24,8 +24,23 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
});
/** Hand a fresh copy of the Contact card to the view. */
export function publishContextView() {
const view = this.contextView;
this.publishContext({
...view,
cohorts: Object.fromEntries(
Object.entries(view.cohorts).map(([id, cohort]) => [id, { ...cohort }]),
),
nearest: { ...view.nearest },
distance: { ...view.distance },
direction: { ...view.direction },
});
}
export function updateContext(info, heading) {
if (!this.context) return;
const view = this.contextView;
const snapshot =
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
const trackedId = info.icao24 || info.id;
@@ -45,35 +60,30 @@ export function updateContext(info, heading) {
this.context.hidden = false;
this.hud?.classList.add('context-active');
if (this.contextSubject) {
const installationCoverage = snapshot.cohorts.find(
(cohort) => cohort.id === 'military-installations',
)?.coverage;
this.contextSubject.textContent = formatCockpitContextScope(
snapshot.subject.label || trackedId,
snapshot.radiusM,
installationCoverage,
LOCALIZED_CONTEXT_SCOPE,
);
}
const installationCoverage = snapshot.cohorts.find(
(cohort) => cohort.id === 'military-installations',
)?.coverage;
view.subject = formatCockpitContextScope(
snapshot.subject.label || trackedId,
snapshot.radiusM,
installationCoverage,
LOCALIZED_CONTEXT_SCOPE,
);
// Navigation stays wired in every state — the operator must always be able
// to step off the current contact from the panel that hosts the controls.
if (this.contextPrevious)
this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
if (this.contextNext)
this.contextNext.disabled = !snapshot.navigation?.canNext;
view.canPrevious = Boolean(snapshot.navigation?.canPrevious);
view.canNext = Boolean(snapshot.navigation?.canNext);
if (readout.contactLost) {
// The subject left its source. Every number below is measured against a
// position that stopped updating, so hold the last rendered readout and
// say so instead of re-deriving stale geometry as if it were live.
const enteringLost = this.context.dataset.state !== 'lost';
this.context.dataset.state = 'lost';
if (this.contextUncertainty) {
this.contextUncertainty.textContent = t(
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
}
const enteringLost = view.state !== 'lost';
view.state = 'lost';
view.uncertainty = t(
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
this.publishContextView();
// The cue changes the footer's height; re-run layout once on the way in
// rather than every frame the contact stays lost.
if (enteringLost) this.scheduleContextLayout();
@@ -91,45 +101,38 @@ export function updateContext(info, heading) {
let unknownCount = 0;
const nearest = [];
for (const cohort of snapshot.cohorts) {
const element = this.contextCohorts.get(cohort.id);
const value = element?.querySelector('strong');
if (value)
value.textContent = cohort.count === null ? '?' : String(cohort.count);
element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN');
const shown = view.cohorts[cohort.id];
if (shown) {
shown.count = cohort.count === null ? '?' : String(cohort.count);
shown.unknown = cohort.relationship === 'UNKNOWN';
}
if (cohort.count === null) unknownCount += 1;
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
}
nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity));
const closest = nearest[0] || null;
const closestLabel = this.services.formatAwarenessLabel(closest);
if (this.contextNearestLabel) {
this.contextNearestLabel.textContent = closest
? t('{cohort} · {contact}', {
cohort: closest.cohort.label.toUpperCase(),
contact: closestLabel,
})
: t('NO AVAILABLE EXAMPLE');
this.contextNearestLabel.setAttribute(
'aria-label',
const nearestLabel = closest
? t('{cohort} · {contact}', {
cohort: closest.cohort.label.toUpperCase(),
contact: closestLabel,
})
: t('NO AVAILABLE EXAMPLE');
view.nearest = {
label: nearestLabel,
ariaLabel:
closest && closestLabel === '—'
? t('{cohort}, Unavailable', {
cohort: closest.cohort.label,
})
: this.contextNearestLabel.textContent,
);
}
if (this.contextDistance) {
const distanceM = closest?.distanceM;
this.contextDistance.textContent = Number.isFinite(distanceM)
? formatCockpitDistance(distanceM)
: '—';
this.contextDistance.setAttribute(
'aria-label',
Number.isFinite(distanceM)
? this.contextDistance.textContent
: t('Unavailable'),
);
}
? t('{cohort}, Unavailable', { cohort: closest.cohort.label })
: nearestLabel,
};
const distanceM = closest?.distanceM;
const distance = Number.isFinite(distanceM)
? formatCockpitDistance(distanceM)
: '—';
view.distance = {
text: distance,
ariaLabel: Number.isFinite(distanceM) ? distance : t('Unavailable'),
};
// The arrow and BRG are nose-relative to the tracked aircraft. When the
// subject is a vessel, an installation, or another aircraft, the rest of
@@ -154,36 +157,27 @@ export function updateContext(info, heading) {
: null;
relative = relativeBearing(bearing, heading);
}
if (this.contextDirection) {
this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
this.contextDirection.classList.toggle('unknown', relative === null);
view.direction = { angle: relative ?? 0, unknown: relative === null };
if (relative === null) view.bearing = t('BRG —');
else if (Math.abs(relative) < 8) view.bearing = t('AHEAD');
else {
view.bearing = t('{side} {angle}', {
side: relative < 0 ? t('L') : t('R'),
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
if (this.contextBearing) {
if (relative === null) this.contextBearing.textContent = t('BRG —');
else if (Math.abs(relative) < 8)
this.contextBearing.textContent = t('AHEAD');
else {
this.contextBearing.textContent = t('{side} {angle}', {
side: relative < 0 ? t('L') : t('R'),
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
}
if (this.contextUncertainty) {
this.contextUncertainty.textContent = unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
}
if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
}
this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
view.uncertainty = unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
view.updated = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
view.state = unknownCount ? 'uncertain' : 'current';
this.publishContextView();
this.updateCockpitSignals(snapshot, unknownCount);
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
this.contextLayoutStamp = snapshot.evaluatedAt;
+244
View File
@@ -0,0 +1,244 @@
// src/ui/cockpitContext.test.mjs — the Cockpit Contact card renders what the
// Cockpit controller presents, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { setLocale, t as translate } from '../i18n/index.js';
import { publishContextView, updateContext } from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import { syncWeatherToggle } from './cockpitInstruments.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitContext from './views/panels/CockpitContext.vue';
function fixture(t, snapshot = null) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitContext).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useCockpitStore(pinia);
const $ = (selector) => document.querySelector(selector);
const signals = [];
const layouts = [];
// The Contact presenter needs only this much of the controller.
const owner = {
context: $('#cockpit-context'),
hud: document.createElement('section'),
contextView: createContextState(),
contextLayoutStamp: null,
publishContext: (view) => store.publishContext(view),
publishContextView,
pushCockpitSignal: (...args) => signals.push(args),
updateCockpitSignals() {},
scheduleContextLayout: () => layouts.push(true),
services: {
militaryAwarenessLayer: { getContextSnapshot: () => owner.snapshot },
formatAwarenessLabel: (item) => item?.id ?? '—',
},
snapshot,
};
const text = (selector) => $(selector).textContent.trim();
return { $, text, owner, signals, layouts };
}
const INFO = {
layerId: 'flights',
icao24: 'abc123',
latitude: 30,
longitude: -97,
};
const snapshot = (overrides = {}) => ({
subject: { layerId: 'military', id: 'm1', label: 'RCH123' },
radiusM: 250_000,
evaluatedAt: Date.UTC(2026, 9, 31, 12, 34, 56),
navigation: { canPrevious: false, canNext: true },
cohorts: [
{
id: 'flights',
label: 'Flights',
count: 12,
relationship: 'OBSERVED',
nearest: [{ id: 'N1', distanceM: 4200 }],
},
{
id: 'military',
label: 'Military',
count: null,
relationship: 'UNKNOWN',
nearest: [],
},
],
...overrides,
});
test('before the controller presents, the card shows the state it starts in', (t) => {
const { $, text } = fixture(t);
const card = $('#cockpit-context');
assert.equal(card.hidden, true);
assert.equal(card.hasAttribute('data-state'), false);
assert.equal(text('#cockpit-context-subject'), 'CONTACTS · 250 KM');
assert.equal($('#cockpit-context-previous').disabled, false);
assert.equal($('#cockpit-context-next').disabled, false);
assert.deepEqual(
[...document.querySelectorAll('[data-context-cohort] strong')].map(
(count) => count.textContent,
),
['—', '—', '—', '—'],
);
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
assert.equal(
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
false,
);
assert.equal($('#cockpit-context-direction').getAttribute('style'), null);
assert.equal(
text('#cockpit-context-uncertainty'),
'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR',
);
assert.equal(text('#cockpit-context-updated'), '--:--:--Z');
const weather = $('#cockpit-weather-toggle');
assert.equal(weather.getAttribute('aria-pressed'), 'false');
assert.equal(
weather.getAttribute('aria-label'),
'Enable cockpit weather effects',
);
assert.equal(weather.title, 'Enable cockpit weather effects');
assert.equal(text('#cockpit-weather-state'), 'OFF');
});
test('a snapshot fills the card and keeps classes other code sets', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
const flights = $('[data-context-cohort="flights"]');
flights.classList.add('voice-highlight');
updateContext.call(owner, INFO, 0);
await nextTick();
const card = $('#cockpit-context');
assert.equal(card.hidden, false);
assert.equal(card.dataset.state, 'uncertain');
assert.equal(owner.hud.classList.contains('context-active'), true);
assert.match(
text('#cockpit-context-subject'),
/^RCH123 · 250\u00a0KM AIR\/SEA WINDOW$/,
);
assert.equal($('#cockpit-context-previous').disabled, true);
assert.equal($('#cockpit-context-next').disabled, false);
assert.equal(flights.querySelector('strong').textContent, '12');
assert.equal(flights.classList.contains('unknown'), false);
assert.equal(flights.classList.contains('voice-highlight'), true);
const military = $('[data-context-cohort="military"]');
assert.equal(military.querySelector('strong').textContent, '?');
assert.equal(military.classList.contains('unknown'), true);
assert.equal(text('#cockpit-context-nearest-label'), 'FLIGHTS · N1');
assert.equal(
$('#cockpit-context-nearest-label').getAttribute('aria-label'),
'FLIGHTS · N1',
);
assert.match(text('#cockpit-context-distance'), /^4\.2/);
// The tracked aircraft is not the subject: the nose-relative half dashes.
assert.equal(text('#cockpit-context-bearing'), 'BRG —');
const direction = $('#cockpit-context-direction');
assert.equal(direction.style.transform, 'rotate(0deg)');
assert.equal(direction.classList.contains('unknown'), true);
assert.equal(direction.classList.contains('material-symbols-outlined'), true);
assert.equal(
text('#cockpit-context-uncertainty'),
'1 INPUT UNKNOWN · NOT AN ALL-CLEAR',
);
assert.equal(text('#cockpit-context-updated'), '12:34:56Z');
assert.equal(layouts.length, 1);
});
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
const { $, text, owner, layouts } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
await nextTick();
const readout = () =>
[
'#cockpit-context-nearest-label',
'#cockpit-context-distance',
'#cockpit-context-bearing',
'#cockpit-context-updated',
].map(text);
const held = readout();
owner.snapshot = snapshot({
subjectPresent: false,
navigation: { canPrevious: true, canNext: false },
cohorts: [{ id: 'flights', label: 'Flights', count: 1, nearest: [] }],
});
updateContext.call(owner, INFO, 0);
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'lost');
assert.equal(
text('#cockpit-context-uncertainty'),
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
// Navigation follows the lost snapshot; the readout holds.
assert.deepEqual(readout(), held);
assert.equal($('#cockpit-context-previous').disabled, false);
assert.equal($('#cockpit-context-next').disabled, true);
assert.equal($('[data-context-cohort="flights"] strong').textContent, '12');
// Layout reruns once on the way in, not every update.
assert.equal(layouts.length, 2);
});
test('without a snapshot the card stands by, hidden', async (t) => {
const { $, owner, signals } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
owner.snapshot = null;
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').hidden, true);
assert.equal(owner.hud.classList.contains('context-active'), false);
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
});
test('the weather toggle renders its state and the action it offers', async (t) => {
const { $, text, owner } = fixture(t);
syncWeatherToggle.call(owner, true);
await nextTick();
const weather = $('#cockpit-weather-toggle');
assert.equal(weather.getAttribute('aria-pressed'), 'true');
assert.equal(
weather.getAttribute('aria-label'),
'Disable cockpit weather effects',
);
assert.equal(weather.title, 'Disable cockpit weather effects');
assert.equal(text('#cockpit-weather-state'), 'ON');
assert.equal(owner.contextView.weather, true);
});
test('re-renders leave the card visibility and layout to the controller', async (t) => {
const { $, owner } = fixture(t, snapshot());
updateContext.call(owner, INFO, 0);
const card = $('#cockpit-context');
card.dataset.collapsed = 'true';
card.dataset.layoutMode = 'compact-bottom';
owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) });
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal(card.hidden, false);
assert.equal(card.dataset.collapsed, 'true');
assert.equal(card.dataset.layoutMode, 'compact-bottom');
});
test('the card is labelled in the UI language', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const { $, text, owner } = fixture(t);
const subject = translate('CONTACTS · 250 KM');
assert.notEqual(subject, 'CONTACTS · 250 KM');
assert.equal(text('#cockpit-context-subject'), subject);
assert.equal(text('#cockpit-weather-state'), translate('OFF'));
syncWeatherToggle.call(owner, true);
await nextTick();
assert.equal(text('#cockpit-weather-state'), translate('ON'));
assert.equal(
$('#cockpit-weather-toggle').getAttribute('aria-label'),
translate('Disable cockpit weather effects'),
);
});
+38
View File
@@ -0,0 +1,38 @@
/**
* The Cockpit Contact card's state as it first renders. It needs only the
* i18n module, so the controller and the view can both read it.
*/
import { t } from '../i18n/index.js';
/** Cohorts the card counts, in its order. */
const COCKPIT_CONTEXT_COHORTS = Object.freeze([
'flights',
'military',
'ais-live-vessels',
'military-installations',
]);
/**
* The card before any snapshot: navigation on, counts and readouts empty,
* weather off. The controller keeps it, changes it in place and publishes a
* copy; `CockpitContext` renders it.
* @returns {object}
*/
export function createContextState() {
return {
state: null,
subject: t('CONTACTS · 250 KM'),
canPrevious: true,
canNext: true,
cohorts: Object.fromEntries(
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
),
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
distance: { text: '—', ariaLabel: null },
direction: { angle: null, unknown: false },
bearing: '—',
uncertainty: t('AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR'),
updated: '--:--:--Z',
weather: false,
};
}
+11 -25
View File
@@ -19,7 +19,8 @@ import {
cycleVisionMode,
clearPredictiveRoute,
} from './cockpitInstruments.js';
import { updateContext } from './cockpitContext.js';
import { publishContextView, updateContext } from './cockpitContext.js';
import { createContextState } from './cockpitContextState.js';
import { createBriefState } from './cockpitBriefPages.js';
import {
publishBriefView,
@@ -60,7 +61,7 @@ export class CockpitViewController {
onEntered = null,
onExited = null,
restoreTrackingFrame = null,
publishBrief = () => {},
publish = {},
} = {},
) {
this.viewer = viewer;
@@ -138,29 +139,10 @@ export class CockpitViewController {
this.isEntryAllowed =
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.context = document.getElementById('cockpit-context');
this.contextSubject = document.getElementById('cockpit-context-subject');
this.contextNearestLabel = document.getElementById(
'cockpit-context-nearest-label',
);
this.contextBearing = document.getElementById('cockpit-context-bearing');
this.contextDistance = document.getElementById('cockpit-context-distance');
this.contextDirection = document.getElementById(
'cockpit-context-direction',
);
this.contextUncertainty = document.getElementById(
'cockpit-context-uncertainty',
);
this.contextUpdated = document.getElementById('cockpit-context-updated');
this.contextCohorts = new Map(
Array.from(document.querySelectorAll('[data-context-cohort]')).map(
(element) => [element.dataset.contextCohort, element],
),
);
this.contextPrevious = document.getElementById('cockpit-context-previous');
this.contextNext = document.getElementById('cockpit-context-next');
this.contextToggle = document.getElementById('cockpit-context-toggle');
this.weatherToggle = document.getElementById('cockpit-weather-toggle');
this.weatherState = document.getElementById('cockpit-weather-state');
this.contextCollapsed = false;
this.signalStream = document.getElementById('cockpit-signal-stream');
this.signalList = document.getElementById('cockpit-signal-list');
@@ -177,8 +159,10 @@ export class CockpitViewController {
this.signalSignatures = new Map();
this.briefPageIndex = 0;
this.briefAutoRotateEnabled = false;
// CockpitBriefing renders what publishBrief receives.
this.publishBrief = publishBrief;
// Cockpit's views render what these receive.
this.publishBrief = publish.brief ?? (() => {});
this.publishContext = publish.context ?? (() => {});
this.contextView = createContextState();
this.brief = createBriefState();
this.briefTimer = null;
this.lastAircraftInfo = null;
@@ -232,8 +216,7 @@ export class CockpitViewController {
this.setContextCollapsed(!this.contextCollapsed),
);
this._listen(this.weatherToggle, 'click', () => {
const enabled =
this.weatherToggle.getAttribute('aria-pressed') !== 'true';
const enabled = !this.contextView.weather;
this.syncWeatherToggle(enabled);
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', {
@@ -330,6 +313,9 @@ export class CockpitViewController {
clearPredictiveRoute() {
return clearPredictiveRoute.call(this);
}
publishContextView() {
return publishContextView.call(this);
}
updateContext(info, heading) {
return updateContext.call(this, info, heading);
}
+35
View File
@@ -209,3 +209,38 @@ test('stopped Cockpit cannot rearm briefing rotation or request a fresh region',
env.restore();
}
});
test('the weather toggle flips from its own state, however fast it is pressed', () => {
const env = environment();
try {
const toggle = new EventTarget();
document.getElementById = (id) =>
id === 'cockpit-weather-toggle' ? toggle : null;
const requested = [];
window.addEventListener('gev:cockpit-weather-toggle', (event) =>
requested.push(event.detail.enabled),
);
const published = [];
const owner = new CockpitViewController(env.viewer, {
services: {},
publish: { context: (view) => published.push(view.weather) },
});
// Two presses before any view renders still alternate.
toggle.dispatchEvent(new Event('click'));
toggle.dispatchEvent(new Event('click'));
assert.deepEqual(requested, [true, false]);
assert.deepEqual(published, [true, false]);
// The effects owner reports its state; the next press flips that.
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-state', {
detail: { enabled: true },
}),
);
toggle.dispatchEvent(new Event('click'));
assert.deepEqual(requested, [true, false, false]);
assert.deepEqual(published, [true, false, true, false]);
owner.dispose();
} finally {
env.restore();
}
});
+1 -1
View File
@@ -82,7 +82,7 @@ export class CockpitCoordinator {
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
publishBrief: (view) => this.publishCockpitBrief?.(view),
publish: this.publishCockpit,
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
restoreTrackingFrame: (entity) => {
+2 -11
View File
@@ -245,17 +245,8 @@ export function updateRoute(info) {
}
export function syncWeatherToggle(enabled) {
if (!this.weatherToggle) return;
const active = !!enabled;
this.weatherToggle.setAttribute('aria-pressed', String(active));
const weatherLabel = active
? t('Disable cockpit weather effects')
: t('Enable cockpit weather effects');
this.weatherToggle.setAttribute('aria-label', weatherLabel);
this.weatherToggle.title = weatherLabel;
if (this.weatherState) {
this.weatherState.textContent = active ? t('ON') : t('OFF');
}
this.contextView.weather = !!enabled;
this.publishContextView();
}
export function setVisionMode(mode, { revealParameters = false } = {}) {
+11 -2
View File
@@ -4,7 +4,8 @@ import { markRaw, shallowRef } from 'vue';
/**
* Cockpit content as its views render it. The Cockpit controller presents
* the briefing card's pages, cycle toggle, regional news and local
* conditions and publishes them here; it keeps the controls' listeners.
* conditions, and the Contact card's readouts and weather toggle, and
* publishes them here; it keeps the controls' listeners.
*/
export const useCockpitStore = defineStore('gev-cockpit', () => {
/** The presented briefing; null until the controller first presents it. */
@@ -15,5 +16,13 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
brief.value = next && markRaw(next);
}
return { brief, publishBrief };
/** The presented Contact card; null until the controller first presents it. */
const context = shallowRef(null);
/** @param {?object} next A fresh presentation of the Contact card. */
function publishContext(next) {
context.value = next && markRaw(next);
}
return { brief, publishBrief, context, publishContext };
});
+2 -50
View File
@@ -1,5 +1,6 @@
<script setup>
import CockpitBriefing from './panels/CockpitBriefing.vue';
import CockpitContext from './panels/CockpitContext.vue';
</script>
<template>
@@ -158,56 +159,7 @@ import CockpitBriefing from './panels/CockpitBriefing.vue';
<span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span>
</div>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label>
<header class="cockpit-context-header">
<div>
<span data-i18n class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject" data-i18n>CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
</nav>
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
<div role="listitem" data-context-cohort="flights"><span data-i18n>FLT</span><strong>—</strong><small translate="no">OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span data-i18n>MIL</span><strong>—</strong><small translate="no">adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>—</strong><small translate="no">AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span data-i18n>SITE</span><strong>—</strong><small translate="no">OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div>
<small data-i18n>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label" data-i18n>NO AVAILABLE EXAMPLE</strong>
</div>
<span id="cockpit-context-bearing">—</span>
<span id="cockpit-context-distance">—</span>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty" data-i18n>AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<button
id="cockpit-weather-toggle"
class="cockpit-context-weather"
type="button"
aria-label="Enable cockpit weather effects"
aria-pressed="false"
title="Enable cockpit weather effects" data-i18n-aria-label data-i18n-title
>
<span data-i18n>WX</span>
<strong data-i18n id="cockpit-weather-state">OFF</strong>
</button>
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</aside>
<CockpitContext />
<CockpitBriefing />
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label>
<div class="cockpit-utility-control">
+72
View File
@@ -0,0 +1,72 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { t } from '../../../i18n/index.js';
import { createContextState } from '../../cockpitContextState.js';
import { vClassToggle } from '../../rail/classToggle.js';
import { useCockpitStore } from '../../stores/cockpit.js';
// The Cockpit Contact card. The Cockpit controller presents its scope,
// navigation, cohort counts, nearest contact and weather toggle; until it
// does, the card shows the state it starts in. Its visibility, collapse and
// layout stay with the controller, and the controls keep its listeners.
const { context } = storeToRefs(useCockpitStore());
const initial = createContextState();
const c = computed(() => context.value ?? initial);
const directionStyle = computed(() =>
c.value.direction.angle === null
? undefined
: { transform: `rotate(${c.value.direction.angle}deg)` },
);
const weatherLabel = computed(() =>
c.value.weather
? t('Disable cockpit weather effects')
: t('Enable cockpit weather effects'),
);
</script>
<template>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden data-i18n-aria-label :data-state="c.state ?? undefined">
<header class="cockpit-context-header">
<div>
<span data-i18n class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject">{{ c.subject }}</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation" data-i18n-aria-label>
<button id="cockpit-context-previous" type="button" :disabled="!c.canPrevious" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" :disabled="!c.canNext" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
</nav>
<span class="cockpit-context-qualifier" data-i18n>CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts" data-i18n-aria-label>
<div v-class-toggle="{ unknown: c.cohorts.flights.unknown }" role="listitem" data-context-cohort="flights"><span data-i18n>FLT</span><strong>{{ c.cohorts.flights.count }}</strong><small translate="no">OpenSky</small></div>
<div v-class-toggle="{ unknown: c.cohorts.military.unknown }" role="listitem" data-context-cohort="military"><span data-i18n>MIL</span><strong>{{ c.cohorts.military.count }}</strong><small translate="no">adsb.lol</small></div>
<div v-class-toggle="{ unknown: c.cohorts['ais-live-vessels'].unknown }" role="listitem" data-context-cohort="ais-live-vessels"><span translate="no">AIS</span><strong>{{ c.cohorts['ais-live-vessels'].count }}</strong><small translate="no">AISStream</small></div>
<div v-class-toggle="{ unknown: c.cohorts['military-installations'].unknown }" role="listitem" data-context-cohort="military-installations"><span data-i18n>SITE</span><strong>{{ c.cohorts['military-installations'].count }}</strong><small translate="no">OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" v-class-toggle="{ unknown: c.direction.unknown }" class="material-symbols-outlined" :style="directionStyle" aria-hidden="true">navigation</span>
<div>
<small data-i18n>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label" :aria-label="c.nearest.ariaLabel ?? undefined">{{ c.nearest.label }}</strong>
</div>
<span id="cockpit-context-bearing">{{ c.bearing }}</span>
<span id="cockpit-context-distance" :aria-label="c.distance.ariaLabel ?? undefined">{{ c.distance.text }}</span>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty">{{ c.uncertainty }}</span>
<button id="cockpit-weather-toggle" class="cockpit-context-weather" type="button" :aria-label="weatherLabel" :aria-pressed="String(c.weather)" :title="weatherLabel">
<span data-i18n>WX</span>
<strong id="cockpit-weather-state">{{ c.weather ? t('ON') : t('OFF') }}</strong>
</button>
<time id="cockpit-context-updated">{{ c.updated }}</time>
</footer>
</aside>
</template>