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

The Cockpit controller no longer writes the briefing card's pages, cycle
toggle, regional news and local conditions: it keeps them as plain state,
starting from cockpitBriefPages.js, and publishes a copy to a new
gev-cockpit store. A new CockpitBriefing component, lifted verbatim from
the Cockpit view, renders it. News rows are keyed by story, so a focused
link never turns into another story on refresh. The controls keep the
controller's listeners; the route card and the signal list keep their
owners' bindings for now. The boot-time kicker translation is gone, as
the card translates its own text.
This commit is contained in:
Sameh Khamis committed 2026-10-31 13:56:31 -07:00
1 parent d134aefd77
commit 82bbf16a8f
13 files changed
+612 -288

No files matched your search

+6 -1
View File
@@ -127,7 +127,12 @@ They contain no application state.
publishes to the `gev-display` store, teleporting the tab bar and sheet publishes to the `gev-display` store, teleporting the tab bar and sheet
backdrop to the end of the page from the first publish until the shell 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 is destroyed; the shell takes their clicks from the document), then
Cockpit. Layers that write into the right rail 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. directly gain a slot instead.
4. **Cleanup.** The build-time template expansion and the attribute 4. **Cleanup.** The build-time template expansion and the attribute
+8
View File
@@ -27,6 +27,8 @@
"src/ui/views/panels/RadioPanel.vue", "src/ui/views/panels/RadioPanel.vue",
"src/ui/views/panels/SdrCard.vue", "src/ui/views/panels/SdrCard.vue",
"src/ui/views/panels/PhoneTabBar.vue", "src/ui/views/panels/PhoneTabBar.vue",
"src/ui/views/panels/CockpitBriefing.vue",
"src/ui/cockpitBriefPages.js",
"src/ui/views/panels/MissionDetail.vue", "src/ui/views/panels/MissionDetail.vue",
"src/ui/views/panels/MissionRoster.vue", "src/ui/views/panels/MissionRoster.vue",
"src/ui/views/panels/AwarenessPanel.vue", "src/ui/views/panels/AwarenessPanel.vue",
@@ -59,6 +61,7 @@
"src/ui/stores/cctv.js", "src/ui/stores/cctv.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/cockpit.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/streetLevel.js", "src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue", "src/ui/rail/RailChips.vue",
@@ -723,6 +726,7 @@
"modules": [ "modules": [
"src/ui/cockpit.js", "src/ui/cockpit.js",
"src/ui/cockpitBriefing.js", "src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitCamera.js", "src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js", "src/ui/cockpitContext.js",
"src/ui/cockpitController.js", "src/ui/cockpitController.js",
@@ -842,6 +846,7 @@
"src/ui/clearLayersControl.js", "src/ui/clearLayersControl.js",
"src/ui/cockpit.js", "src/ui/cockpit.js",
"src/ui/cockpitBriefing.js", "src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitCamera.js", "src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js", "src/ui/cockpitContext.js",
"src/ui/cockpitController.js", "src/ui/cockpitController.js",
@@ -975,6 +980,7 @@
"src/ui/stores/cctv.js", "src/ui/stores/cctv.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/cockpit.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/display.js" "src/ui/stores/display.js"
], ],
@@ -2541,6 +2547,7 @@
"src/ui/clearLayersControl.js", "src/ui/clearLayersControl.js",
"src/ui/cockpit.js", "src/ui/cockpit.js",
"src/ui/cockpitBriefing.js", "src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitCamera.js", "src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js", "src/ui/cockpitContext.js",
"src/ui/cockpitController.js", "src/ui/cockpitController.js",
@@ -2946,6 +2953,7 @@
"src/ui/stores/cctv.js", "src/ui/stores/cctv.js",
"src/ui/stores/radio.js", "src/ui/stores/radio.js",
"src/ui/stores/context.js", "src/ui/stores/context.js",
"src/ui/stores/cockpit.js",
"src/ui/stores/location.js", "src/ui/stores/location.js",
"src/ui/stores/display.js" "src/ui/stores/display.js"
], ],
+21 -10
View File
@@ -23,7 +23,11 @@ import {
setContextCollapsed, setContextCollapsed,
setSignalCollapsed, setSignalCollapsed,
} from './ui/cockpitLayout.js'; } from './ui/cockpitLayout.js';
import { setBriefAutoRotate } from './ui/cockpitBriefing.js'; import { createBriefState } from './ui/cockpitBriefPages.js';
import {
publishBriefView,
setBriefAutoRotate,
} from './ui/cockpitBriefing.js';
import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js'; import { CockpitDisplayPortal } from './ui/cockpitDisplayPortal.js';
import { _handleContextLayerChange } from './ui/contextLayerChanges.js'; import { _handleContextLayerChange } from './ui/contextLayerChanges.js';
import { clearSelectedLayers } from './ui/contextActions.js'; import { clearSelectedLayers } from './ui/contextActions.js';
@@ -1269,15 +1273,22 @@ test('cockpit briefing cycle control keeps its state as the accessible name', ()
/title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./, /title="Cycle briefing pages automatically every 9 seconds \(Signals → News → Local\)\./,
); );
const update = setBriefAutoRotate.toString(); // The presented label is the state; the help is only the tooltip.
assert.ok(update, 'cockpit briefing cycle state updater is missing'); const views = [];
assert.match( const owner = {
update, brief: createBriefState(),
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/, publishBrief: (view) => views.push(view),
); publishBriefView,
assert.match(update, /setAttribute\('aria-label', label\)/); startBriefRotation() {},
assert.match(update, /\.title = help;/); stopBriefRotation() {},
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/); };
setBriefAutoRotate.call(owner, true);
assert.equal(views.at(-1).autoLabel, 'CYCLE ON');
assert.equal(views.at(-1).autoRotate, true);
assert.match(views.at(-1).autoHelp, /^Stop automatic page cycling\./);
setBriefAutoRotate.call(owner, false);
assert.equal(views.at(-1).autoLabel, 'CYCLE OFF');
assert.match(views.at(-1).autoHelp, /^Cycle briefing pages automatically/);
}); });
test('voice Cockpit entry honours a requested contact layer before it enters', () => { test('voice Cockpit entry honours a requested contact layer before it enters', () => {
+3 -5
View File
@@ -57,6 +57,7 @@ import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js'; import { useContextStore } from './stores/context.js';
import { useDisplayStore } from './stores/display.js'; import { useDisplayStore } from './stores/display.js';
import { useLocationStore } from './stores/location.js'; import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js';
/** /**
* Central UI orchestrator for the God's Eye View application. * Central UI orchestrator for the God's Eye View application.
@@ -374,6 +375,8 @@ export class StyleManager extends ShellFacade {
_models3dBtn: this._models3dBtn, _models3dBtn: this._models3dBtn,
}, },
operations: { operations: {
publishCockpitBrief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view),
_layoutRightPanels: (...args) => this._layoutRightPanels(...args), _layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args), _setCockpitVision: (...args) => this._setCockpitVision(...args),
_stampNavigation: (...args) => this._stampNavigation(...args), _stampNavigation: (...args) => this._stampNavigation(...args),
@@ -833,11 +836,6 @@ export class StyleManager extends ShellFacade {
), ),
); );
} }
// Leading space after the live dot, as showBriefPage writes it.
const briefKicker = document.querySelector('#cockpit-brief-kicker');
if (briefKicker) {
this._setHeaderText(briefKicker, ` ${t('LIVE SIGNALS')}`);
}
} }
/** /**
+76
View File
@@ -0,0 +1,76 @@
/**
* The Cockpit briefing's pages and the state it starts in. It needs only the
* i18n module, so the controller and the view can both read it.
*/
import { msg, t } from '../i18n/index.js';
export const COCKPIT_BRIEF_PAGES = [
{
id: 'signals',
kickerKey: msg('LIVE SIGNALS'),
subtitleKey: msg('OBSERVED / MAPPED PINGS'),
sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
},
{
id: 'news',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
},
{
id: 'local',
kickerKey: msg('LOCAL INFO'),
subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
// Attribution stays English.
source: 'NATURAL EARTH · OPEN-METEO · UTC',
},
];
// The cycle toggle's tooltip, as literal keys for the extractor.
export const CYCLE_HELP_KEYS = Object.freeze({
on: msg(
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
),
off: msg(
'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.',
),
});
/**
* The briefing as it first renders: the Live Signals page, cycling off, news
* still loading and no region yet. The controller keeps it, changes it in
* place and publishes a copy; `CockpitBriefing` renders it.
* @returns {object}
*/
export function createBriefState() {
const page = COCKPIT_BRIEF_PAGES[0];
return {
pageIndex: 0,
pageId: page.id,
kicker: t(page.kickerKey),
subtitle: t(page.subtitleKey),
position: `1 / ${COCKPIT_BRIEF_PAGES.length}`,
source: t(page.sourceKey),
autoRotate: false,
autoLabel: t('CYCLE OFF'),
autoHelp: t(CYCLE_HELP_KEYS.off),
regionalStatus: null,
news: {
statusHidden: false,
state: null,
status: t('ACQUIRING REGIONAL NEWS'),
articles: [],
},
local: {
place: t('RESOLVING REGION'),
coordinates: '--',
temperature: '—',
wind: '—',
windDirection: '—',
condition: t('UNKNOWN'),
cloud: '—',
precipitation: '—',
},
};
}
+96 -144
View File
@@ -2,15 +2,13 @@
import { formatNumber, t, msg } from '../i18n/index.js'; import { formatNumber, t, msg } from '../i18n/index.js';
import { import {
COCKPIT_BRIEF_ROTATE_MS, COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
COCKPIT_BRIEF_CYCLE_ON_HELP,
COCKPIT_REGIONAL_REFRESH_MS, COCKPIT_REGIONAL_REFRESH_MS,
COCKPIT_REGIONAL_REFRESH_DISTANCE_M, COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
formatCockpitBriefAge, formatCockpitBriefAge,
formatCockpitCoordinate, formatCockpitCoordinate,
formatCockpitWindDirection, formatCockpitWindDirection,
} from './cockpitPresentation.js'; } from './cockpitPresentation.js';
import { COCKPIT_BRIEF_PAGES, CYCLE_HELP_KEYS } from './cockpitBriefPages.js';
// regionalModel.weatherCodeLabel is shared with node code and returns // regionalModel.weatherCodeLabel is shared with node code and returns
// English; translate it here. // English; translate it here.
@@ -29,35 +27,32 @@ const WEATHER_CONDITION_KEYS = Object.freeze({
'MIXED CONDITIONS': msg('MIXED CONDITIONS'), 'MIXED CONDITIONS': msg('MIXED CONDITIONS'),
}); });
/** Hand a fresh copy of the briefing to the view. */
export function publishBriefView() {
const brief = this.brief;
this.publishBrief({
...brief,
news: { ...brief.news },
local: { ...brief.local },
});
}
export function showBriefPage(index, { manual = false } = {}) { export function showBriefPage(index, { manual = false } = {}) {
if (this.destroyed) return; if (this.destroyed) return;
const count = COCKPIT_BRIEF_PAGES.length; const count = COCKPIT_BRIEF_PAGES.length;
this.briefPageIndex = ((Number(index) % count) + count) % count; this.briefPageIndex = ((Number(index) % count) + count) % count;
const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex]; const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
this.briefPages.forEach((element) => { Object.assign(this.brief, {
element.hidden = element.dataset.cockpitBriefPage !== page.id; pageIndex: this.briefPageIndex,
}); pageId: page.id,
this.briefTabs.forEach((button) => { kicker: t(page.kickerKey),
const current = subtitle: t(page.subtitleKey),
Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex; position: `${this.briefPageIndex + 1} / ${count}`,
button.setAttribute('aria-current', current ? 'true' : 'false'); source: page.sourceKey
});
if (this.briefKicker) {
const indicator = this.briefKicker.querySelector('i');
this.briefKicker.replaceChildren(
...[indicator, document.createTextNode(` ${t(page.kickerKey)}`)].filter(
Boolean,
),
);
}
if (this.briefSubtitle) this.briefSubtitle.textContent = t(page.subtitleKey);
if (this.briefPosition)
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
if (this.briefSource)
this.briefSource.textContent = page.sourceKey
? t(page.sourceKey, { source: page.sourceName }) ? t(page.sourceKey, { source: page.sourceName })
: page.source; : page.source,
if (this.signalStream) this.signalStream.dataset.briefPage = page.id; });
this.publishBriefView();
if (manual && this.briefAutoRotateEnabled) if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true }); this.startBriefRotation({ reset: true });
this.scheduleContextLayout(); this.scheduleContextLayout();
@@ -66,35 +61,13 @@ export function showBriefPage(index, { manual = false } = {}) {
export function setBriefAutoRotate(enabled) { export function setBriefAutoRotate(enabled) {
if (this.destroyed) return; if (this.destroyed) return;
this.briefAutoRotateEnabled = Boolean(enabled); this.briefAutoRotateEnabled = Boolean(enabled);
if (this.briefAutoToggle) { // The state is the accessible name; the help is the tooltip.
this.briefAutoToggle.setAttribute( Object.assign(this.brief, {
'aria-pressed', autoRotate: this.briefAutoRotateEnabled,
String(this.briefAutoRotateEnabled), autoLabel: this.briefAutoRotateEnabled ? t('CYCLE ON') : t('CYCLE OFF'),
); autoHelp: t(CYCLE_HELP_KEYS[this.briefAutoRotateEnabled ? 'on' : 'off']),
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF'; });
this.briefAutoToggle.textContent = label; this.publishBriefView();
const help = this.briefAutoRotateEnabled
? COCKPIT_BRIEF_CYCLE_ON_HELP
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
this.briefAutoToggle.setAttribute('aria-label', label);
this.briefAutoToggle.title = help;
// cockpitMarkup.test.mjs pins the English writes above; these translate
// them.
const labelKey = this.briefAutoRotateEnabled
? msg('CYCLE ON')
: msg('CYCLE OFF');
this.briefAutoToggle.textContent = t(labelKey);
this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
this.briefAutoToggle.title = t(
this.briefAutoRotateEnabled
? msg(
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
)
: msg(
'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.',
),
);
}
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true }); if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation(); else this.stopBriefRotation();
} }
@@ -129,15 +102,20 @@ export function stopBriefRotation() {
this.briefTimer = null; this.briefTimer = null;
} }
export function updateLocalPosition(info) { /** @returns {string} The position line for the Local page. */
if (!this.localCoordinates) return; function localCoordinates(info) {
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) { if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude))
this.localCoordinates.textContent = t('POSITION UNAVAILABLE'); return t('POSITION UNAVAILABLE');
return;
}
const lat = formatCockpitCoordinate(info.latitude, 'N', 'S'); const lat = formatCockpitCoordinate(info.latitude, 'N', 'S');
const lon = formatCockpitCoordinate(info.longitude, 'E', 'W'); const lon = formatCockpitCoordinate(info.longitude, 'E', 'W');
this.localCoordinates.textContent = `${lat} · ${lon}`; return `${lat} · ${lon}`;
}
export function updateLocalPosition(info) {
const coordinates = localCoordinates(info);
if (coordinates === this.brief.local.coordinates) return;
this.brief.local.coordinates = coordinates;
this.publishBriefView();
} }
export function maybeRefreshRegionalBrief(info) { export function maybeRefreshRegionalBrief(info) {
@@ -208,105 +186,79 @@ export function maybeRefreshRegionalBrief(info) {
} }
export function renderRegionalBriefStatus(status, info) { export function renderRegionalBriefStatus(status, info) {
if (this.newsStatus) { const { news, local } = this.brief;
this.newsStatus.hidden = false; news.statusHidden = false;
this.newsStatus.dataset.state = status; news.state = status;
this.newsStatus.textContent = news.status =
status === 'loading' status === 'loading'
? t('ACQUIRING REGIONAL NEWS') ? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE'); : t('REGIONAL NEWS UNAVAILABLE');
} if (status === 'unavailable') news.articles = [];
if (status === 'unavailable') this.newsList?.replaceChildren(); if (status === 'loading') local.place = t('RESOLVING REGION');
if (this.localPlace && status === 'loading') if (status === 'unavailable') local.place = t('REGION UNAVAILABLE');
this.localPlace.textContent = t('RESOLVING REGION'); local.coordinates = localCoordinates(info);
if (this.localPlace && status === 'unavailable') this.publishBriefView();
this.localPlace.textContent = t('REGION UNAVAILABLE');
this.updateLocalPosition(info);
} }
export function renderRegionalBrief(payload, info) { export function renderRegionalBrief(payload, info) {
const articles = Array.isArray(payload?.articles) ? payload.articles : []; const articles = Array.isArray(payload?.articles) ? payload.articles : [];
if (this.newsStatus) { const seen = new Map();
this.newsStatus.hidden = articles.length > 0; const storyKey = (url) => {
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable'; const count = seen.get(url) ?? 0;
this.newsStatus.textContent = seen.set(url, count + 1);
payload?.newsStatus === 'empty' return count ? `${url}#${count}` : String(url);
? t('NO RECENT LOCATION MATCHES') };
: t('REGIONAL NEWS UNAVAILABLE');
}
if (this.newsList) {
this.newsList.replaceChildren(
...articles.slice(0, 4).map((article) => {
const entry = document.createElement('li');
const link = document.createElement('a');
link.href = article.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
const title = document.createElement('strong');
title.textContent = article.title;
const metadata = document.createElement('span');
metadata.textContent = t('{domain} · {age}', {
domain: article.domain || t('SOURCE'),
age: formatCockpitBriefAge(article.publishedAt),
});
link.append(title, metadata);
entry.append(link);
return entry;
}),
);
}
const placeLabel =
payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info);
const weather = payload?.weather; const weather = payload?.weather;
if (this.localTemperature) {
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
? `${Math.round(weather.temperatureC)}°C`
: '—';
}
if (this.localWind) {
this.localWind.textContent = Number.isFinite(weather?.windKph)
? t('{value} KM/H', { value: Math.round(weather.windKph) })
: '—';
}
if (this.localWindDirection) {
this.localWindDirection.textContent = formatCockpitWindDirection(
weather?.windDirectionDeg,
);
}
if (this.localCondition) {
const condition = this.services.weatherCodeLabel(weather?.weatherCode); const condition = this.services.weatherCodeLabel(weather?.weatherCode);
const conditionKey = WEATHER_CONDITION_KEYS[condition]; const conditionKey = WEATHER_CONDITION_KEYS[condition];
this.localCondition.textContent = conditionKey const placeLabel =
? t(conditionKey) payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
: condition; this.brief.news = {
} statusHidden: articles.length > 0,
if (this.localCloud) { state: payload?.newsStatus || 'unavailable',
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct) status:
? t('CLOUD {pct}%', { payload?.newsStatus === 'empty'
pct: Math.round(weather.cloudCoverPct), ? t('NO RECENT LOCATION MATCHES')
}) : t('REGIONAL NEWS UNAVAILABLE'),
: t('CLOUD UNKNOWN'); articles: articles.slice(0, 4).map((article) => ({
} // A row keeps its node, and so its focus, only for the same story.
if (this.localPrecipitation) { key: storyKey(article.url),
this.localPrecipitation.textContent = Number.isFinite( url: article.url,
weather?.precipitationMm, title: article.title,
) meta: t('{domain} · {age}', {
domain: article.domain || t('SOURCE'),
age: formatCockpitBriefAge(article.publishedAt),
}),
})),
};
this.brief.local = {
place: placeLabel.toUpperCase(),
coordinates: localCoordinates(info),
temperature: Number.isFinite(weather?.temperatureC)
? `${Math.round(weather.temperatureC)}°C`
: '—',
wind: Number.isFinite(weather?.windKph)
? t('{value} KM/H', { value: Math.round(weather.windKph) })
: '—',
windDirection: formatCockpitWindDirection(weather?.windDirectionDeg),
condition: conditionKey ? t(conditionKey) : condition,
cloud: Number.isFinite(weather?.cloudCoverPct)
? t('CLOUD {pct}%', { pct: Math.round(weather.cloudCoverPct) })
: t('CLOUD UNKNOWN'),
precipitation: Number.isFinite(weather?.precipitationMm)
? formatNumber(weather.precipitationMm, { ? formatNumber(weather.precipitationMm, {
useGrouping: false, useGrouping: false,
minimumFractionDigits: 1, minimumFractionDigits: 1,
maximumFractionDigits: 1, maximumFractionDigits: 1,
}) })
: '—'; : '—',
} };
if (this.signalStream) this.brief.regionalStatus = payload?.status || 'partial';
this.signalStream.dataset.regionalStatus = payload?.status || 'partial'; if (this.briefPageIndex === 1) {
if (this.briefPageIndex === 1 && this.briefSource) { this.brief.source = t('{source} · LOCATION QUERY', {
this.briefSource.textContent = t('{source} · LOCATION QUERY', {
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(), source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
}); });
} }
this.publishBriefView();
this.scheduleContextLayout(); this.scheduleContextLayout();
} }
+277
View File
@@ -0,0 +1,277 @@
// src/ui/cockpitBriefing.test.mjs — the Cockpit briefing 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 } from '../i18n/index.js';
import { createBriefState } from './cockpitBriefPages.js';
import {
publishBriefView,
renderRegionalBrief,
renderRegionalBriefStatus,
setBriefAutoRotate,
showBriefPage,
updateLocalPosition,
} from './cockpitBriefing.js';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitBriefing from './views/panels/CockpitBriefing.vue';
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitBriefing).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useCockpitStore(pinia);
// The briefing presenters need only this much of the controller.
const owner = {
destroyed: false,
briefPageIndex: 0,
briefAutoRotateEnabled: false,
brief: createBriefState(),
publishBrief: (view) => store.publishBrief(view),
publishBriefView,
scheduleContextLayout() {},
startBriefRotation() {},
stopBriefRotation() {},
services: { weatherCodeLabel: () => 'RAIN' },
};
const $ = (selector) => document.querySelector(selector);
const text = (selector) => $(selector).textContent.trim();
const visiblePages = () =>
[...document.querySelectorAll('[data-cockpit-brief-page]')]
.filter((page) => !page.hidden)
.map((page) => page.dataset.cockpitBriefPage);
const current = () =>
[...document.querySelectorAll('[data-cockpit-brief-index]')].map((tab) =>
tab.getAttribute('aria-current'),
);
return { $, text, owner, visiblePages, current };
}
const INFO = { latitude: 30.25, longitude: -97.75 };
test('before the controller presents, the card shows the state it starts in', (t) => {
const { $, text, visiblePages, current } = fixture(t);
assert.equal(text('#cockpit-brief-kicker'), 'LIVE SIGNALS');
assert.equal(text('#cockpit-brief-subtitle'), 'OBSERVED / MAPPED PINGS');
assert.equal(text('#cockpit-brief-position'), '1 / 3');
assert.equal(
text('#cockpit-brief-source'),
'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
);
assert.deepEqual(visiblePages(), ['signals']);
assert.deepEqual(current(), ['true', null, null]);
const auto = $('#cockpit-brief-auto');
assert.equal(auto.textContent, 'CYCLE OFF');
assert.equal(auto.getAttribute('aria-label'), 'CYCLE OFF');
assert.equal(auto.getAttribute('aria-pressed'), 'false');
assert.match(auto.title, /^Cycle briefing pages automatically/);
assert.equal(text('#cockpit-news-status'), 'ACQUIRING REGIONAL NEWS');
assert.equal($('#cockpit-news-status').hasAttribute('data-state'), false);
assert.equal(text('#cockpit-local-place'), 'RESOLVING REGION');
assert.equal(text('#cockpit-local-coordinates'), '--');
assert.equal(text('#cockpit-local-condition'), 'UNKNOWN');
const stream = $('#cockpit-signal-stream');
assert.equal(stream.hidden, true);
assert.equal(stream.hasAttribute('data-brief-page'), false);
assert.equal(stream.hasAttribute('data-regional-status'), false);
});
test('pages, the cycle toggle and the source follow the controller', async (t) => {
const { $, text, owner, visiblePages, current } = fixture(t);
showBriefPage.call(owner, 1);
await nextTick();
assert.equal(text('#cockpit-brief-kicker'), 'REGIONAL NEWS');
assert.equal(text('#cockpit-brief-position'), '2 / 3');
assert.equal(
text('#cockpit-brief-source'),
'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
);
assert.deepEqual(visiblePages(), ['news']);
assert.deepEqual(current(), ['false', 'true', 'false']);
assert.equal($('#cockpit-signal-stream').dataset.briefPage, 'news');
showBriefPage.call(owner, -1);
await nextTick();
assert.deepEqual(visiblePages(), ['local']);
assert.equal(
text('#cockpit-brief-source'),
'NATURAL EARTH · OPEN-METEO · UTC',
);
setBriefAutoRotate.call(owner, true);
await nextTick();
const auto = $('#cockpit-brief-auto');
assert.equal(auto.textContent, 'CYCLE ON');
assert.equal(auto.getAttribute('aria-label'), 'CYCLE ON');
assert.equal(auto.getAttribute('aria-pressed'), 'true');
assert.match(auto.title, /^Stop automatic page cycling/);
});
test('regional news and local conditions render what the region returned', async (t) => {
const { $, text, owner } = fixture(t);
renderRegionalBriefStatus.call(owner, 'loading', INFO);
await nextTick();
assert.equal($('#cockpit-news-status').dataset.state, 'loading');
assert.match(text('#cockpit-local-coordinates'), /N · .*W$/);
showBriefPage.call(owner, 1);
renderRegionalBrief.call(
owner,
{
status: 'ok',
newsStatus: 'ok',
newsSource: 'wire',
place: { label: 'Austin, Texas' },
articles: Array.from({ length: 5 }, (_, index) => ({
url: `https://example.com/${index}`,
title: `Story ${index}`,
domain: 'example.com',
publishedAt: new Date().toISOString(),
})),
weather: {
temperatureC: 21.6,
windKph: 12.2,
windDirectionDeg: 90,
weatherCode: 61,
cloudCoverPct: 40,
precipitationMm: 1.25,
},
},
INFO,
);
await nextTick();
assert.equal($('#cockpit-news-status').hidden, true);
const links = [...document.querySelectorAll('#cockpit-news-list li a')];
assert.deepEqual(
links.map((link) => link.querySelector('strong').textContent),
['Story 0', 'Story 1', 'Story 2', 'Story 3'],
);
assert.equal(links[0].getAttribute('href'), 'https://example.com/0');
assert.equal(links[0].target, '_blank');
assert.equal(links[0].rel, 'noopener noreferrer');
assert.match(links[0].querySelector('span').textContent, /^example\.com · /);
assert.equal(text('#cockpit-local-place'), 'AUSTIN, TEXAS');
assert.equal(text('#cockpit-local-temperature'), '22°C');
assert.equal(text('#cockpit-local-wind'), '12 KM/H');
assert.equal(text('#cockpit-local-condition'), 'RAIN');
assert.equal(text('#cockpit-local-cloud'), 'CLOUD 40%');
assert.equal(text('#cockpit-local-precipitation'), '1.3');
assert.equal(text('#cockpit-brief-source'), 'WIRE · LOCATION QUERY');
assert.equal($('#cockpit-signal-stream').dataset.regionalStatus, 'ok');
renderRegionalBriefStatus.call(owner, 'unavailable', {});
await nextTick();
assert.equal($('#cockpit-news-status').hidden, false);
assert.equal(text('#cockpit-news-status'), 'REGIONAL NEWS UNAVAILABLE');
assert.equal(document.querySelectorAll('#cockpit-news-list li').length, 0);
assert.equal(text('#cockpit-local-place'), 'REGION UNAVAILABLE');
assert.equal(text('#cockpit-local-coordinates'), 'POSITION UNAVAILABLE');
});
const story = (id, url = `https://example.com/${id}`) => ({
url,
title: `Story ${id}`,
domain: 'example.com',
publishedAt: new Date().toISOString(),
});
test('a refreshed news list keeps a focused story on its own link', async (t) => {
const { $, owner } = fixture(t);
const render = (articles) =>
renderRegionalBrief.call(owner, { newsStatus: 'ok', articles }, INFO);
render([story('a'), story('b'), story('c')]);
await nextTick();
const link = (title) =>
[...document.querySelectorAll('#cockpit-news-list a')].find(
(anchor) => anchor.querySelector('strong').textContent === title,
);
const focused = link('Story b');
focused.focus();
render([story('b'), story('d'), story('a')]);
await nextTick();
// Focus may drop when rows move, as it did when the list was rebuilt,
// but a link never turns into another story under it.
assert.equal(link('Story b') === focused, true);
assert.equal(focused.getAttribute('href'), 'https://example.com/b');
const active = document.activeElement;
assert.equal(
active === focused || !$('#cockpit-news-list').contains(active),
true,
);
// Two stories at one address still render as two rows.
render([
story('x', 'https://example.com/same'),
story('y', 'https://example.com/same'),
]);
await nextTick();
assert.equal(document.querySelectorAll('#cockpit-news-list li').length, 2);
assert.equal($('#cockpit-news-list').textContent.includes('Story y'), true);
});
test('the news source names the feed only while it is current on page 2', async (t) => {
const { text, owner } = fixture(t);
showBriefPage.call(owner, 1);
renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO);
await nextTick();
assert.equal(text('#cockpit-brief-source'), 'WIRE · LOCATION QUERY');
showBriefPage.call(owner, 2);
showBriefPage.call(owner, 1);
await nextTick();
assert.equal(
text('#cockpit-brief-source'),
'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
);
showBriefPage.call(owner, 0);
renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO);
await nextTick();
assert.equal(
text('#cockpit-brief-source'),
'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
);
});
test('an unchanged position publishes nothing new', (t) => {
const { owner } = fixture(t);
const views = [];
owner.publishBrief = (view) => views.push(view);
updateLocalPosition.call(owner, INFO);
updateLocalPosition.call(owner, INFO);
assert.equal(views.length, 1);
});
test('re-renders leave the signal rows and route card the controller owns', async (t) => {
const { $, text, owner } = fixture(t);
const list = $('#cockpit-signal-list');
const route = $('#cockpit-route');
const row = document.createElement('li');
row.textContent = 'N123AB';
list.append(row);
$('#cockpit-route-from').textContent = 'AUS';
$('#cockpit-route').hidden = false;
$('#cockpit-signal-stream').hidden = false;
showBriefPage.call(owner, 2);
renderRegionalBriefStatus.call(owner, 'loading', INFO);
await nextTick();
assert.equal($('#cockpit-signal-list') === list, true);
assert.equal($('#cockpit-route') === route, true);
assert.equal(list.firstElementChild === row, true);
assert.equal(text('#cockpit-signal-list'), 'N123AB');
assert.equal(text('#cockpit-route-from'), 'AUS');
assert.equal($('#cockpit-route').hidden, false);
assert.equal($('#cockpit-signal-stream').hidden, false);
});
test('the card is labelled in the UI language', async (t) => {
t.after(() => setLocale('en'));
setLocale('ru');
const { $, text, owner } = fixture(t);
const auto = $('#cockpit-brief-auto');
assert.equal(text('#cockpit-brief-kicker'), 'СИГНАЛЫ ОНЛАЙН');
assert.equal(auto.textContent, 'АВТО: ВЫКЛ');
assert.equal(auto.getAttribute('aria-label'), 'АВТО: ВЫКЛ');
assert.match(auto.title, /^Автоматически листать/);
showBriefPage.call(owner, 1);
await nextTick();
assert.equal(text('#cockpit-brief-kicker'), 'НОВОСТИ РЕГИОНА');
});
+9 -25
View File
@@ -20,7 +20,9 @@ import {
clearPredictiveRoute, clearPredictiveRoute,
} from './cockpitInstruments.js'; } from './cockpitInstruments.js';
import { updateContext } from './cockpitContext.js'; import { updateContext } from './cockpitContext.js';
import { createBriefState } from './cockpitBriefPages.js';
import { import {
publishBriefView,
showBriefPage, showBriefPage,
setBriefAutoRotate, setBriefAutoRotate,
startBriefRotation, startBriefRotation,
@@ -58,6 +60,7 @@ export class CockpitViewController {
onEntered = null, onEntered = null,
onExited = null, onExited = null,
restoreTrackingFrame = null, restoreTrackingFrame = null,
publishBrief = () => {},
} = {}, } = {},
) { ) {
this.viewer = viewer; this.viewer = viewer;
@@ -162,43 +165,21 @@ export class CockpitViewController {
this.signalStream = document.getElementById('cockpit-signal-stream'); this.signalStream = document.getElementById('cockpit-signal-stream');
this.signalList = document.getElementById('cockpit-signal-list'); this.signalList = document.getElementById('cockpit-signal-list');
this.signalToggle = document.getElementById('cockpit-signal-toggle'); this.signalToggle = document.getElementById('cockpit-signal-toggle');
this.briefKicker = document.getElementById('cockpit-brief-kicker');
this.briefSubtitle = document.getElementById('cockpit-brief-subtitle');
this.briefPrevious = document.getElementById('cockpit-brief-previous'); this.briefPrevious = document.getElementById('cockpit-brief-previous');
this.briefNext = document.getElementById('cockpit-brief-next'); this.briefNext = document.getElementById('cockpit-brief-next');
this.briefAutoToggle = document.getElementById('cockpit-brief-auto'); this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
this.briefPosition = document.getElementById('cockpit-brief-position');
this.briefSource = document.getElementById('cockpit-brief-source');
this.briefPages = Array.from(
document.querySelectorAll('[data-cockpit-brief-page]'),
);
this.briefTabs = Array.from( this.briefTabs = Array.from(
document.querySelectorAll('[data-cockpit-brief-index]'), document.querySelectorAll('[data-cockpit-brief-index]'),
); );
this.newsStatus = document.getElementById('cockpit-news-status');
this.newsList = document.getElementById('cockpit-news-list');
this.localPlace = document.getElementById('cockpit-local-place');
this.localCoordinates = document.getElementById(
'cockpit-local-coordinates',
);
this.localTemperature = document.getElementById(
'cockpit-local-temperature',
);
this.localWind = document.getElementById('cockpit-local-wind');
this.localWindDirection = document.getElementById(
'cockpit-local-wind-direction',
);
this.localCondition = document.getElementById('cockpit-local-condition');
this.localCloud = document.getElementById('cockpit-local-cloud');
this.localPrecipitation = document.getElementById(
'cockpit-local-precipitation',
);
this.signalCollapsed = false; this.signalCollapsed = false;
this.signalUserCollapsed = false; this.signalUserCollapsed = false;
this.signalItems = []; this.signalItems = [];
this.signalSignatures = new Map(); this.signalSignatures = new Map();
this.briefPageIndex = 0; this.briefPageIndex = 0;
this.briefAutoRotateEnabled = false; this.briefAutoRotateEnabled = false;
// CockpitBriefing renders what publishBrief receives.
this.publishBrief = publishBrief;
this.brief = createBriefState();
this.briefTimer = null; this.briefTimer = null;
this.lastAircraftInfo = null; this.lastAircraftInfo = null;
this.regionalBrief = null; this.regionalBrief = null;
@@ -352,6 +333,9 @@ export class CockpitViewController {
updateContext(info, heading) { updateContext(info, heading) {
return updateContext.call(this, info, heading); return updateContext.call(this, info, heading);
} }
publishBriefView() {
return publishBriefView.call(this);
}
showBriefPage(index, options) { showBriefPage(index, options) {
return showBriefPage.call(this, index, options); return showBriefPage.call(this, index, options);
} }
+1
View File
@@ -82,6 +82,7 @@ export class CockpitCoordinator {
flightsEnabled: !!this._dataManager?.isEnabled('flights'), flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'), militaryEnabled: !!this._dataManager?.isEnabled('military'),
}), }),
publishBrief: (view) => this.publishCockpitBrief?.(view),
onEntered: () => this.enterPanels(), onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(), onExited: () => this.exitPanels(),
restoreTrackingFrame: (entity) => { restoreTrackingFrame: (entity) => {
-29
View File
@@ -34,39 +34,10 @@ export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
export const COCKPIT_BRIEF_ROTATE_MS = 9000; export const COCKPIT_BRIEF_ROTATE_MS = 9000;
export const COCKPIT_BRIEF_CYCLE_OFF_HELP =
'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.';
export const COCKPIT_BRIEF_CYCLE_ON_HELP =
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.';
export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000; export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000; export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
export const COCKPIT_BRIEF_PAGES = [
{
id: 'signals',
kickerKey: msg('LIVE SIGNALS'),
subtitleKey: msg('OBSERVED / MAPPED PINGS'),
sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
},
{
id: 'news',
kickerKey: msg('REGIONAL NEWS'),
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
},
{
id: 'local',
kickerKey: msg('LOCAL INFO'),
subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
// Attribution stays English.
source: 'NATURAL EARTH · OPEN-METEO · UTC',
},
];
export function isRenderedOnScreen(element) { export function isRenderedOnScreen(element) {
if (!element) return false; if (!element) return false;
for (let node = element; node instanceof Element; node = node.parentElement) { for (let node = element; node instanceof Element; node = node.parentElement) {
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia';
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.
*/
export const useCockpitStore = defineStore('gev-cockpit', () => {
/** The presented briefing; null until the controller first presents it. */
const brief = shallowRef(null);
/** @param {?object} next A fresh presentation of the briefing card. */
function publishBrief(next) {
brief.value = next && markRaw(next);
}
return { brief, publishBrief };
});
+5 -74
View File
@@ -1,3 +1,7 @@
<script setup>
import CockpitBriefing from './panels/CockpitBriefing.vue';
</script>
<template> <template>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. --> <!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label> <section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden data-i18n-aria-label>
@@ -204,80 +208,7 @@
<time id="cockpit-context-updated">--:--:--Z</time> <time id="cockpit-context-updated">--:--:--Z</time>
</footer> </footer>
</aside> </aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label> <CockpitBriefing />
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n>ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span data-i18n>FROM</span>
<strong id="cockpit-route-from" data-i18n>UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span data-i18n>TO</span>
<strong id="cockpit-route-to" data-i18n>UNKNOWN</strong>
</div>
</div>
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker" data-i18n-runtime><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle" data-i18n>OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button
id="cockpit-brief-auto"
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way." data-i18n-title data-i18n-aria-label data-i18n
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button>
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label>
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden data-i18n-aria-label>
<div id="cockpit-news-status" class="cockpit-brief-status" data-i18n>ACQUIRING REGIONAL NEWS</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden data-i18n-aria-label>
<div class="cockpit-local-place">
<strong id="cockpit-local-place" data-i18n>RESOLVING REGION</strong>
<small id="cockpit-local-coordinates">--</small>
</div>
<div class="cockpit-local-grid">
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">—</strong><small translate="no">OPEN-METEO</small></div>
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition" data-i18n>UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small data-i18n>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener" data-i18n>Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source" data-i18n>SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages" data-i18n-aria-label>
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true" data-i18n-aria-label data-i18n>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" data-i18n-aria-label data-i18n>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" data-i18n-aria-label data-i18n>LOCAL</button>
</nav>
</footer>
</aside>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls" data-i18n-aria-label> <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"> <div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display"> <div class="cockpit-utility-launcher" data-cockpit-launcher="display">
+91
View File
@@ -0,0 +1,91 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { createBriefState } from '../../cockpitBriefPages.js';
import { useCockpitStore } from '../../stores/cockpit.js';
// The Cockpit briefing card. The Cockpit controller presents its pages,
// cycle toggle, regional news and local conditions; until it does, the card
// shows the state it starts in. The controls keep the controller's
// listeners. The route card and the signal list keep their owners'
// bindings, so nothing here binds them.
const { brief } = storeToRefs(useCockpitStore());
const initial = createBriefState();
const b = computed(() => brief.value ?? initial);
// The card's page and the region's answer, as state hooks on the card.
const streamState = computed(() => ({
'data-brief-page': brief.value?.pageId,
'data-regional-status': brief.value?.regionalStatus || undefined,
}));
</script>
<template>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label v-bind="streamState">
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden data-i18n-aria-label>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker" data-i18n>ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status" data-i18n>ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span data-i18n>FROM</span>
<strong id="cockpit-route-from" data-i18n>UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span data-i18n>TO</span>
<strong id="cockpit-route-to" data-i18n>UNKNOWN</strong>
</div>
</div>
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> {{ b.kicker }}</span>
<strong id="cockpit-brief-subtitle">{{ b.subtitle }}</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls" data-i18n-aria-label>
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">{{ b.position }}</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button id="cockpit-brief-auto" type="button" :aria-label="b.autoLabel" :aria-pressed="String(b.autoRotate)" :title="b.autoHelp">{{ b.autoLabel }}</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel" data-i18n-aria-label data-i18n-title>
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button>
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals" data-i18n-aria-label :hidden="b.pageId !== 'signals'">
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" data-i18n-aria-label :hidden="b.pageId !== 'news'">
<div id="cockpit-news-status" class="cockpit-brief-status" :hidden="b.news.statusHidden" :data-state="b.news.state ?? undefined">{{ b.news.status }}</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"><li v-for="article in b.news.articles" :key="article.key"><a :href="article.url" target="_blank" rel="noopener noreferrer"><strong>{{ article.title }}</strong><span>{{ article.meta }}</span></a></li></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" data-i18n-aria-label :hidden="b.pageId !== 'local'">
<div class="cockpit-local-place">
<strong id="cockpit-local-place">{{ b.local.place }}</strong>
<small id="cockpit-local-coordinates">{{ b.local.coordinates }}</small>
</div>
<div class="cockpit-local-grid">
<div><span data-i18n>TEMP</span><strong id="cockpit-local-temperature">{{ b.local.temperature }}</strong><small translate="no">OPEN-METEO</small></div>
<div><span data-i18n>WIND</span><strong id="cockpit-local-wind">{{ b.local.wind }}</strong><small id="cockpit-local-wind-direction">{{ b.local.windDirection }}</small></div>
<div><span data-i18n>SKY</span><strong id="cockpit-local-condition">{{ b.local.condition }}</strong><small id="cockpit-local-cloud">{{ b.local.cloud }}</small></div>
<div><span data-i18n>PRECIP</span><strong id="cockpit-local-precipitation">{{ b.local.precipitation }}</strong><small data-i18n>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener" data-i18n>Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source">{{ b.source }}</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages" data-i18n-aria-label>
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" :aria-current="String(b.pageIndex === 0)" data-i18n-aria-label data-i18n>SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News" :aria-current="brief ? String(b.pageIndex === 1) : undefined" data-i18n-aria-label data-i18n>NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info" :aria-current="brief ? String(b.pageIndex === 2) : undefined" data-i18n-aria-label data-i18n>LOCAL</button>
</nav>
</footer>
</aside>
</template>