Files
gods-eye-view/src/ui/cockpitBriefing.js
T
Denis Groshev f75179eea0 feat(cockpit): an application can add its own cockpit subjects; the Rail and Transit layers expose the read and hold APIs such a subject needs
GEV itself registers no new subject: with no provider, the Cockpit offers
aircraft, satellites and vessels as before, except that the most recently
selected of them now wins entry.

- services.cockpitSubjects: providers ({kind, layerId, entryFace, priority,
  isEntryAllowed, read, hold, release, hud}) are checked once by the
  coordinator; frames and HUD views on every update; a throwing provider
  ends its view or keeps the last HUD.
- Entry: the most recently selected subject wins, across all kinds.
- Camera: a frame may turn the ground clamp off, anchor exactly on the
  subject and update every frame.
- A generic HUD: kicker, title, cards, always-visible source rows, banner,
  a veil with light streaks (off under reduced motion) and a bottom
  progress line whose close ticks merge.
- Text comes from the provider, already in the page's language
  (./i18n/locale: getLocale, onLocaleChange).
- Rail and Transit read and hold APIs (getSelectedTrain/Vehicle,
  getCockpitSubject, setCockpitSubject, focusTrain/Vehicle); a held
  train's marker, card and route line are hidden, and its feed keeps
  polling while held.
- ./layers/rail/paths is exported.
2026-11-12 10:50:26 +01:00

384 lines
13 KiB
JavaScript

/** Regional briefing requests, pages and rotation for the Cockpit controller. */
import { formatNumber, t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_REGIONAL_REFRESH_MS,
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
formatCockpitBriefAge,
formatCockpitCoordinate,
formatCockpitWindDirection,
} from './cockpitPresentation.js';
import {
COCKPIT_BRIEF_PAGES,
CYCLE_HELP_KEYS,
createBriefState,
newsWindowFooter,
} from './cockpitBriefPages.js';
import {
recentRegionalArticles,
regionalNewsWindowHours,
} from '../data/regionalNewsWindow.js';
const NEWS_ROWS = 4;
// regionalModel.weatherCodeLabel is shared with node code and returns
// English; translate it here.
const WEATHER_CONDITION_KEYS = Object.freeze({
'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'),
CLEAR: msg('CLEAR'),
'PARTLY CLOUDY': msg('PARTLY CLOUDY'),
OVERCAST: msg('OVERCAST'),
FOG: msg('FOG'),
DRIZZLE: msg('DRIZZLE'),
RAIN: msg('RAIN'),
SNOW: msg('SNOW'),
'RAIN SHOWERS': msg('RAIN SHOWERS'),
'SNOW SHOWERS': msg('SNOW SHOWERS'),
THUNDERSTORM: msg('THUNDERSTORM'),
'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];
Object.assign(this.brief, {
pageIndex: this.briefPageIndex,
pageId: page.id,
kicker: t(page.kickerKey),
subtitle: t(page.subtitleKey),
position: `${this.briefPageIndex + 1} / ${count}`,
// The news footer follows the news state, so it survives page changes.
source:
page.id === 'news'
? this.brief.news.footer
: page.sourceKey
? t(page.sourceKey)
: page.source,
});
this.publishBriefView();
if (manual && this.briefAutoRotateEnabled)
this.startBriefRotation({ reset: true });
this.scheduleContextLayout();
}
// The state is the accessible name; the help is the tooltip.
function cycleWords(enabled) {
return {
autoRotate: enabled,
autoLabel: enabled ? t('CYCLE ON') : t('CYCLE OFF'),
autoHelp: t(CYCLE_HELP_KEYS[enabled ? 'on' : 'off']),
};
}
/**
* Word the briefing again in the current locale: its page, cycle toggle and
* whatever the regional card shows.
*/
export function relocalizeBrief() {
if (this.destroyed) return;
if (this.brief.news.state === null) {
const { news, local } = createBriefState();
this.brief.news = news;
this.brief.local = { ...local, coordinates: this.brief.local.coordinates };
}
Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled));
this.showBriefPage(this.briefPageIndex);
if (this.regionalBrief)
this.renderRegionalBrief(this.regionalBrief, this.lastSubjectInfo);
else if (this.brief.news.state)
this.renderRegionalBriefStatus(this.brief.news.state, this.lastSubjectInfo);
}
export function setBriefAutoRotate(enabled) {
if (this.destroyed) return;
this.briefAutoRotateEnabled = Boolean(enabled);
Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled));
this.publishBriefView();
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation();
}
export function startBriefRotation({ reset = false } = {}) {
if (this.destroyed) return;
if (reset) this.stopBriefRotation();
if (
!this.briefAutoRotateEnabled ||
this.briefTimer ||
!this.active ||
this.signalCollapsed ||
document.hidden
)
return;
this.briefTimer = window.setTimeout(() => {
this.briefTimer = null;
if (this.destroyed) return;
const hasPointer = this.signalStream?.matches(':hover') === true;
const hasFocus =
this.signalStream?.contains(document.activeElement) === true;
const isInteracting = hasPointer || hasFocus;
if (!isInteracting) {
this.showBriefPage(this.briefPageIndex + 1);
}
this.startBriefRotation();
}, COCKPIT_BRIEF_ROTATE_MS);
}
export function stopBriefRotation() {
if (this.briefTimer) window.clearTimeout(this.briefTimer);
this.briefTimer = null;
}
/** @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');
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) {
if (this.destroyed) return;
if (
!this.active ||
!Number.isFinite(info.latitude) ||
!Number.isFinite(info.longitude)
)
return;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' || info.generic === true ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`;
if (subjectId !== this.regionalBriefSubjectId) {
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.regionalBriefSubjectId = subjectId;
this.regionalBrief = null;
this.regionalBriefAnchor = null;
this.regionalBriefFetchedAt = 0;
}
const point = { latitude: info.latitude, longitude: info.longitude };
const ageMs = Date.now() - this.regionalBriefFetchedAt;
const distanceM = this.services.regionalDistanceM(
this.regionalBriefAnchor,
point,
);
if (
this.regionalBriefAbort ||
(ageMs < COCKPIT_REGIONAL_REFRESH_MS &&
distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)
)
return;
// The brief held stands in after a failed refresh only near where its news
// was read; past the refresh distance it is another place's.
const previous =
this.services.regionalDistanceM(this.regionalBriefPoint, point) <
COCKPIT_REGIONAL_REFRESH_DISTANCE_M
? this.regionalBrief
: null;
this.regionalBriefAnchor = point;
this.regionalBriefFetchedAt = Date.now();
const controller = new AbortController();
const requestToken = ++this.regionalBriefRequestToken;
this.regionalBriefAbort = controller;
if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
this.services
.fetchRegionalBrief(point.latitude, point.longitude, {
signal: controller.signal,
})
.then((payload) => {
if (
!this.active ||
requestToken !== this.regionalBriefRequestToken ||
subjectId !== this.regionalBriefSubjectId
)
return;
this.regionalBrief = withCachedNews(payload, previous);
if (this.regionalBrief === payload) this.regionalBriefPoint = point;
this.renderRegionalBrief(this.regionalBrief, info);
})
.catch((error) => {
if (
error?.name !== 'AbortError' &&
this.active &&
requestToken === this.regionalBriefRequestToken &&
subjectId === this.regionalBriefSubjectId
) {
if (previous) {
this.regionalBrief = cachedNews(previous, previous);
this.renderRegionalBrief(this.regionalBrief, info);
} else {
// Another place's brief: not to come back with a language change.
this.regionalBrief = null;
this.renderRegionalBriefStatus('unavailable', info);
}
}
})
.finally(() => {
if (this.regionalBriefAbort === controller)
this.regionalBriefAbort = null;
});
}
/**
* A brief whose news comes from `previous` after a refresh that brought none:
* the earlier articles stay, marked `cached` so they are labelled as such.
* @param {object} payload The brief to keep everything else from.
* @param {object} previous The last brief rendered for this subject.
*/
function cachedNews(payload, previous) {
return {
...payload,
newsStatus: 'cached',
newsSource: previous.newsSource,
newsWindowHours: previous.newsWindowHours,
newsFetchedAt: previous.newsFetchedAt ?? previous.retrievedAt ?? null,
articles: previous.articles,
};
}
// A refresh whose news read failed keeps the articles from before it.
function withCachedNews(payload, previous) {
return previous && payload?.newsStatus === 'unavailable'
? cachedNews(payload, previous)
: payload;
}
/**
* The Regional News state a brief presents. `ready` and `cached` have rows
* inside the window; `cached` is a refresh that failed (a client-side failure,
* or the local proxy's `stale` answer). `empty` is a source that answered with
* nothing in the window; anything else is `unavailable`.
* @returns {{state: string, status: string, footer: string, articles: object[]}}
*/
function regionalNewsView(payload) {
const hours = regionalNewsWindowHours(payload?.newsWindowHours);
// Old caches and the local path are held to the window here too.
const articles = recentRegionalArticles(payload?.articles, {
windowHours: hours,
limit: NEWS_ROWS,
});
const source = String(
payload?.newsSource || t('REGIONAL NEWS'),
).toUpperCase();
const refreshFailed =
payload?.newsStatus === 'cached' || payload?.status === 'stale';
if (articles.length && refreshFailed)
return {
state: 'cached',
status: t('CACHED · REFRESH FAILED'),
footer: t('{source} · CACHED {age}', {
source,
age: formatCockpitBriefAge(
payload?.newsFetchedAt ?? payload?.retrievedAt,
),
}),
articles,
};
const answered =
!refreshFailed && ['ready', 'empty'].includes(payload?.newsStatus);
return {
state: articles.length ? 'ready' : answered ? 'empty' : 'unavailable',
status: articles.length
? ''
: answered
? t('NO NEWS IN THE LAST {hours} H', { hours })
: t('REGIONAL NEWS UNAVAILABLE'),
footer: newsWindowFooter(source, hours),
articles,
};
}
export function renderRegionalBriefStatus(status, info) {
const { news, local } = this.brief;
news.statusHidden = false;
news.state = status;
news.status =
status === 'loading'
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
news.footer = newsWindowFooter();
// Only called with no brief for this subject: any rows are another's.
news.articles = [];
if (this.briefPageIndex === 1) this.brief.source = news.footer;
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 { state, status, footer, articles } = regionalNewsView(payload);
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');
this.brief.news = {
statusHidden: state === 'ready',
state,
status,
footer,
articles: articles.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`
: '—',
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, {
useGrouping: false,
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: '—',
};
this.brief.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1) this.brief.source = footer;
this.publishBriefView();
this.scheduleContextLayout();
}