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
+614 -290

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
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. 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.
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/SdrCard.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/MissionRoster.vue",
"src/ui/views/panels/AwarenessPanel.vue",
@@ -59,6 +61,7 @@
"src/ui/stores/cctv.js",
"src/ui/stores/radio.js",
"src/ui/stores/context.js",
"src/ui/stores/cockpit.js",
"src/ui/stores/location.js",
"src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue",
@@ -723,6 +726,7 @@
"modules": [
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -842,6 +846,7 @@
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -975,6 +980,7 @@
"src/ui/stores/cctv.js",
"src/ui/stores/radio.js",
"src/ui/stores/context.js",
"src/ui/stores/cockpit.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js"
],
@@ -2541,6 +2547,7 @@
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitBriefPages.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -2946,6 +2953,7 @@
"src/ui/stores/cctv.js",
"src/ui/stores/radio.js",
"src/ui/stores/context.js",
"src/ui/stores/cockpit.js",
"src/ui/stores/location.js",
"src/ui/stores/display.js"
],
+21 -10
View File
@@ -23,7 +23,11 @@ import {
setContextCollapsed,
setSignalCollapsed,
} 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 { _handleContextLayerChange } from './ui/contextLayerChanges.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\)\./,
);
const update = setBriefAutoRotate.toString();
assert.ok(update, 'cockpit briefing cycle state updater is missing');
assert.match(
update,
/const label = this\.briefAutoRotateEnabled \? 'CYCLE ON' : 'CYCLE OFF';/,
);
assert.match(update, /setAttribute\('aria-label', label\)/);
assert.match(update, /\.title = help;/);
assert.doesNotMatch(update, /setAttribute\('aria-label', help\)/);
// The presented label is the state; the help is only the tooltip.
const views = [];
const owner = {
brief: createBriefState(),
publishBrief: (view) => views.push(view),
publishBriefView,
startBriefRotation() {},
stopBriefRotation() {},
};
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', () => {
+3 -5
View File
@@ -57,6 +57,7 @@ import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js';
import { useDisplayStore } from './stores/display.js';
import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -374,6 +375,8 @@ export class StyleManager extends ShellFacade {
_models3dBtn: this._models3dBtn,
},
operations: {
publishCockpitBrief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view),
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...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: '—',
},
};
}
+98 -146
View File
@@ -2,15 +2,13 @@
import { formatNumber, t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
COCKPIT_BRIEF_CYCLE_ON_HELP,
COCKPIT_REGIONAL_REFRESH_MS,
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
formatCockpitBriefAge,
formatCockpitCoordinate,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
import { COCKPIT_BRIEF_PAGES, CYCLE_HELP_KEYS } from './cockpitBriefPages.js';
// regionalModel.weatherCodeLabel is shared with node code and returns
// English; translate it here.
@@ -29,35 +27,32 @@ const WEATHER_CONDITION_KEYS = Object.freeze({
'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 } = {}) {
if (this.destroyed) return;
const count = COCKPIT_BRIEF_PAGES.length;
this.briefPageIndex = ((Number(index) % count) + count) % count;
const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
this.briefPages.forEach((element) => {
element.hidden = element.dataset.cockpitBriefPage !== page.id;
});
this.briefTabs.forEach((button) => {
const current =
Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
button.setAttribute('aria-current', current ? 'true' : 'false');
});
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
Object.assign(this.brief, {
pageIndex: this.briefPageIndex,
pageId: page.id,
kicker: t(page.kickerKey),
subtitle: t(page.subtitleKey),
position: `${this.briefPageIndex + 1} / ${count}`,
source: page.sourceKey
? t(page.sourceKey, { source: page.sourceName })
: page.source;
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
: page.source,
});
this.publishBriefView();
if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true });
this.scheduleContextLayout();
@@ -66,35 +61,13 @@ export function showBriefPage(index, { manual = false } = {}) {
export function setBriefAutoRotate(enabled) {
if (this.destroyed) return;
this.briefAutoRotateEnabled = Boolean(enabled);
if (this.briefAutoToggle) {
this.briefAutoToggle.setAttribute(
'aria-pressed',
String(this.briefAutoRotateEnabled),
);
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
this.briefAutoToggle.textContent = label;
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.',
),
);
}
// The state is the accessible name; the help is the tooltip.
Object.assign(this.brief, {
autoRotate: this.briefAutoRotateEnabled,
autoLabel: this.briefAutoRotateEnabled ? t('CYCLE ON') : t('CYCLE OFF'),
autoHelp: t(CYCLE_HELP_KEYS[this.briefAutoRotateEnabled ? 'on' : 'off']),
});
this.publishBriefView();
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation();
}
@@ -129,15 +102,20 @@ export function stopBriefRotation() {
this.briefTimer = null;
}
export function updateLocalPosition(info) {
if (!this.localCoordinates) return;
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
return;
}
/** @returns {string} The position line for the Local page. */
function localCoordinates(info) {
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude))
return t('POSITION UNAVAILABLE');
const lat = formatCockpitCoordinate(info.latitude, 'N', 'S');
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) {
@@ -208,105 +186,79 @@ export function maybeRefreshRegionalBrief(info) {
}
export function renderRegionalBriefStatus(status, info) {
if (this.newsStatus) {
this.newsStatus.hidden = false;
this.newsStatus.dataset.state = status;
this.newsStatus.textContent =
status === 'loading'
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading')
this.localPlace.textContent = t('RESOLVING REGION');
if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = t('REGION UNAVAILABLE');
this.updateLocalPosition(info);
const { news, local } = this.brief;
news.statusHidden = false;
news.state = status;
news.status =
status === 'loading'
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
if (status === 'unavailable') news.articles = [];
if (status === 'loading') local.place = t('RESOLVING REGION');
if (status === 'unavailable') local.place = t('REGION UNAVAILABLE');
local.coordinates = localCoordinates(info);
this.publishBriefView();
}
export function renderRegionalBrief(payload, info) {
const articles = Array.isArray(payload?.articles) ? payload.articles : [];
if (this.newsStatus) {
this.newsStatus.hidden = articles.length > 0;
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
this.newsStatus.textContent =
payload?.newsStatus === 'empty'
? 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 seen = new Map();
const storyKey = (url) => {
const count = seen.get(url) ?? 0;
seen.set(url, count + 1);
return count ? `${url}#${count}` : String(url);
};
const weather = payload?.weather;
const condition = this.services.weatherCodeLabel(weather?.weatherCode);
const conditionKey = WEATHER_CONDITION_KEYS[condition];
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;
if (this.localTemperature) {
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
this.brief.news = {
statusHidden: articles.length > 0,
state: payload?.newsStatus || 'unavailable',
status:
payload?.newsStatus === 'empty'
? t('NO RECENT LOCATION MATCHES')
: t('REGIONAL NEWS UNAVAILABLE'),
articles: articles.slice(0, 4).map((article) => ({
// A row keeps its node, and so its focus, only for the same story.
key: storyKey(article.url),
url: article.url,
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`
: '—';
}
if (this.localWind) {
this.localWind.textContent = Number.isFinite(weather?.windKph)
: '—',
wind: 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 conditionKey = WEATHER_CONDITION_KEYS[condition];
this.localCondition.textContent = conditionKey
? t(conditionKey)
: condition;
}
if (this.localCloud) {
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
? t('CLOUD {pct}%', {
pct: Math.round(weather.cloudCoverPct),
})
: t('CLOUD UNKNOWN');
}
if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite(
weather?.precipitationMm,
)
: '—',
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, {
useGrouping: false,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: '—';
}
if (this.signalStream)
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1 && this.briefSource) {
this.briefSource.textContent = t('{source} · LOCATION QUERY', {
: '—',
};
this.brief.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1) {
this.brief.source = t('{source} · LOCATION QUERY', {
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
});
}
this.publishBriefView();
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,
} from './cockpitInstruments.js';
import { updateContext } from './cockpitContext.js';
import { createBriefState } from './cockpitBriefPages.js';
import {
publishBriefView,
showBriefPage,
setBriefAutoRotate,
startBriefRotation,
@@ -58,6 +60,7 @@ export class CockpitViewController {
onEntered = null,
onExited = null,
restoreTrackingFrame = null,
publishBrief = () => {},
} = {},
) {
this.viewer = viewer;
@@ -162,43 +165,21 @@ export class CockpitViewController {
this.signalStream = document.getElementById('cockpit-signal-stream');
this.signalList = document.getElementById('cockpit-signal-list');
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.briefNext = document.getElementById('cockpit-brief-next');
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(
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.signalUserCollapsed = false;
this.signalItems = [];
this.signalSignatures = new Map();
this.briefPageIndex = 0;
this.briefAutoRotateEnabled = false;
// CockpitBriefing renders what publishBrief receives.
this.publishBrief = publishBrief;
this.brief = createBriefState();
this.briefTimer = null;
this.lastAircraftInfo = null;
this.regionalBrief = null;
@@ -352,6 +333,9 @@ export class CockpitViewController {
updateContext(info, heading) {
return updateContext.call(this, info, heading);
}
publishBriefView() {
return publishBriefView.call(this);
}
showBriefPage(index, options) {
return showBriefPage.call(this, index, options);
}
+1
View File
@@ -82,6 +82,7 @@ export class CockpitCoordinator {
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
publishBrief: (view) => this.publishCockpitBrief?.(view),
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
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_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_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) {
if (!element) return false;
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>
<!-- 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>
@@ -204,80 +208,7 @@
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden data-i18n-aria-label>
<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>
<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">
<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>