mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
d134aefd77
commit
82bbf16a8f
13 files changed
+614
-290
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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'), 'НОВОСТИ РЕГИОНА');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user