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