diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index 77f30394c..d056947bd 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 2c61dc94d..546dd0852 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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" ], diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 8b6b8fc1c..86d5e9b3c 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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', () => { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 3c018859d..c334531ae 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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')}`); - } } /** diff --git a/src/ui/cockpitBriefPages.js b/src/ui/cockpitBriefPages.js new file mode 100644 index 000000000..e92488831 --- /dev/null +++ b/src/ui/cockpitBriefPages.js @@ -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: '—', + }, + }; +} diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index 47b100867..5f95a3d96 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -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(); } diff --git a/src/ui/cockpitBriefing.test.mjs b/src/ui/cockpitBriefing.test.mjs new file mode 100644 index 000000000..a31d77a22 --- /dev/null +++ b/src/ui/cockpitBriefing.test.mjs @@ -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'), 'НОВОСТИ РЕГИОНА'); +}); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 9aa19fd05..ad6b7e09d 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -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); } diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 218ec816d..cd426a39a 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -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) => { diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 40602c7b3..61b6961f2 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -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) { diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js new file mode 100644 index 000000000..4bdb09c68 --- /dev/null +++ b/src/ui/stores/cockpit.js @@ -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 }; +}); diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 5e6f78351..18dc08b8b 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -1,3 +1,7 @@ + +