Merge origin/main into fix/hal-722-radio-next-no-autoplay

main moved the Radio markup into Vue views and rebuilt the UI tests on
stores. Resolved:
- src/ui/templates/context.html: deleted in main; the new privacy note now
  lives in src/ui/views/panels/RadioPanel.vue.
- src/ui/radioControls.test.mjs: main's store fixture; the Next/Previous
  autoplay test drives store.act('cycleRadio').
- src/voice/gevRealtime.test.mjs: main's createTestVoiceUi; the stop and
  select handoff cases stay one table-driven test.
- src/data/radioNextState.test.mjs reads the published gev-radio view.
- src/i18n/locales/ar.json: the new note translated for the Arabic locale.
This commit is contained in:
German Khokhlov committed 2026-11-02 12:36:31 +04:00
commit a2be947aba
679 files changed
+45369 -19625

No files matched your search

+18
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createAlprOverlay } from './overlay.js';
import { normalizeAlprNode } from './records.js';
import {
directionWedgePositions,
alprLabelDetails,
@@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => {
);
});
test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => {
const direction = (tag) =>
alprLabelDetails(
normalizeAlprNode({
type: 'node',
id: 1,
lat: 30,
lon: -97,
tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag },
}),
{ label: 'Custom directory' },
).find((line) => line.startsWith('DIRECTION'));
assert.equal(direction('359.6'), 'DIRECTION 0°');
assert.equal(direction('359.4'), 'DIRECTION 359°');
assert.equal(direction('0.4'), 'DIRECTION 0°');
});
test('overlay glyphs and native badges shrink alike with distance', () => {
assert.equal(markerScale(100), 1.15);
assert.equal(markerScale(40_000), 0.45);
+2 -1
View File
@@ -5,6 +5,7 @@ import {
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
@@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) {
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
@@ -0,0 +1,185 @@
// src/layers/awareness/directionOverlay.test.mjs — the Global Context compass
// and contact bearings are presented to subscribers and rendered from a store
// inside the viewer's container.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useAwarenessDirectionStore } from '../../ui/stores/awarenessDirection.js';
import AwarenessDirection from '../../ui/views/panels/AwarenessDirection.vue';
import { createState } from './state.js';
import { createRendering } from './rendering.js';
/** The rendering part over a subject with one military contact due east. */
function overlay(screenAngle) {
const state = createState({ services: {} });
const container = document.body.appendChild(document.createElement('div'));
const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
Object.assign(state, {
viewer: {
container,
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
camera: {
heading: 0,
rightWC: new Cesium.Cartesian3(0, 1, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
},
},
enabled: true,
subject: { position: subject, layerId: 'military' },
results: {
cohorts: [
{
id: 'military',
summary: {
nearest: [
{
callsign: 'RCH123',
position: Cesium.Cartesian3.fromDegrees(0.05, 0, 1000),
distanceM: 5500,
track: 90,
},
],
},
},
],
},
cohortPages: new Map(),
});
const rendering = createRendering({
state,
services: {
geometry: {
getKeyholeGeometry: () => ({ centerX: 400, centerY: 300, radius: 250 }),
celestialScreenAngle:
screenAngle ||
((x, y) => ({
angle: Math.atan2(-y, x),
opacity: 1,
stable: true,
})),
},
render: { governorRequestRender() {} },
},
parts: {},
});
return { state, container, rendering };
}
test('the overlay publishes the compass and the nearest contact bearings', () => {
const { rendering, container } = overlay();
const views = [];
rendering.subscribeDirectionOverlay((view) => views.push(view));
assert.deepEqual(views, [null]);
rendering.updateDirectionOverlay();
const view = views.at(-1);
assert.ok(view.container === container);
assert.equal(view.hidden, false);
// The compass sits at 66% of the 250px keyhole: 165px.
assert.equal(view.ring.width, '330px');
assert.equal(view.ring['--compass-rotation'], '0deg');
assert.equal(view.heading.text, 'HDG 000°');
assert.deepEqual(
view.labels.map((label) => label.label),
['N', 'E', 'S', 'W'],
);
assert.equal(view.labels[0].left, '400px');
const [marker, second] = view.markers;
assert.equal(marker.hidden, false);
assert.match(marker.caption.text, /^RCH123 · .+\nBRG 090° · CRS 090°$/);
assert.equal(marker.arrow.opacity, '1');
assert.equal(second.hidden, true);
// An unchanged frame publishes nothing.
const count = views.length;
rendering.updateDirectionOverlay();
assert.equal(views.length, count);
rendering.hideDirectionOverlay();
assert.equal(views.at(-1).hidden, true);
assert.equal(views.at(-1).heading.text, 'HDG 000°', 'placement is kept');
rendering.removeDirectionOverlay();
assert.equal(views.at(-1), null);
});
test('an unstable frame keeps the last stable bearing, until the overlay is removed', () => {
const answers = [
{ angle: 1, opacity: 1, stable: true },
{ angle: 2, opacity: 0.5, stable: false },
];
const previous = [];
const { state, rendering } = overlay((x, y, last) => {
previous.push(last);
return answers.shift() ?? { angle: last ?? 0, opacity: 1, stable: true };
});
rendering.updateDirectionOverlay();
assert.equal(state.directionAngles[0], 1);
// The heading turns, so the next frame recomputes the contact.
state.viewer.camera.heading = 0.1;
rendering.updateDirectionOverlay();
assert.equal(previous[1], 1, 'the unstable frame is given the last angle');
assert.equal(state.directionAngles[0], 1, 'and does not replace it');
rendering.removeDirectionOverlay();
rendering.updateDirectionOverlay();
assert.equal(previous[2], 0, 'a rebuilt overlay starts afresh');
});
test('the scene chrome view renders the published overlay in its container', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const store = useAwarenessDirectionStore(pinia);
const app = createApp(AwarenessDirection).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const { rendering, container } = overlay();
rendering.subscribeDirectionOverlay((view) => store.publish(view));
rendering.ensureDirectionOverlay();
await nextTick();
const root = container.querySelector('#military-awareness-direction-overlay');
assert.equal(root.hidden, true);
assert.equal(root.getAttribute('aria-hidden'), 'true');
assert.deepEqual(
[...root.children].map((child) => child.className),
[
'military-awareness-compass-label cardinal-n',
'military-awareness-compass-label cardinal-e',
'military-awareness-compass-label cardinal-s',
'military-awareness-compass-label cardinal-w',
'military-awareness-compass-ring',
'military-awareness-compass-heading',
'military-awareness-direction-marker',
'military-awareness-direction-marker',
'military-awareness-direction-marker',
],
);
assert.equal(root.children[0].dataset.bearing, '0');
assert.equal(root.children[0].getAttribute('style'), null);
rendering.updateDirectionOverlay();
await nextTick();
assert.equal(root.hidden, false);
const ring = root.querySelector('.military-awareness-compass-ring');
assert.equal(ring.style.getPropertyValue('--compass-rotation'), '0deg');
assert.equal(
root.querySelector('.military-awareness-compass-heading').textContent,
'HDG 000°',
);
const [marker, second] = root.querySelectorAll(
'.military-awareness-direction-marker',
);
assert.equal(marker.hidden, false);
assert.equal(
marker.querySelector('.military-awareness-direction-arrow').textContent,
'➜',
);
assert.match(
marker.querySelector('.military-awareness-direction-label').textContent,
/^RCH123 · /,
);
assert.equal(second.hidden, true);
rendering.removeDirectionOverlay();
await nextTick();
assert.equal(
container.querySelector('#military-awareness-direction-overlay'),
null,
);
});
+4 -3
View File
@@ -51,12 +51,13 @@ export function createAwarenessLayer({ services, source }) {
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest:
parts.testing._getAwarenessPanelRendersForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
subscribePanel: parts.panel.subscribePanel,
subscribeDirectionOverlay: parts.rendering.subscribeDirectionOverlay,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
+2 -16
View File
@@ -229,22 +229,8 @@ export function createLifecycle({
destroy() {
this.disable();
detachRuntimeListeners();
layerState.panel?.removeEventListener(
'click',
layerState.panelClickListener,
);
layerState.panelClickListener = null;
if (layerState.panelOwned) layerState.panel?.remove();
else if (layerState.panel) layerState.panel.replaceChildren();
layerState.panel = null;
layerState.panelOwned = false;
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
parts.panel.clearPanel();
parts.rendering.removeDirectionOverlay();
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
+132 -164
View File
@@ -2,9 +2,15 @@ import {
formatAwarenessLabel,
AWARENESS_RADIUS_M,
} from '../../data/militaryAwarenessEngine.js';
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
import {
formatNumber,
onLocaleChange,
t,
translateKnown,
} from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js';
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
import { distanceParts } from '../../data/distanceFormat.js';
/** Format a cohort total without presenting a retained cap as exact. */
export function formatAwarenessCount(summary = {}) {
@@ -26,13 +32,11 @@ function presentAwarenessCount(summary = {}) {
/** A distance as the panel shows it: 850 m, 4.6 km, 120 km, in the UI language. */
export function presentAwarenessDistance(meters) {
if (!Number.isFinite(meters) || meters < 0) return '—';
const wholeMeters = Math.round(meters);
if (wholeMeters < 1000) return formatQuantity(wholeMeters, 'm');
const digits = meters < 10000 ? 1 : 0;
return formatQuantity(meters / 1000, 'km', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
const parts = distanceParts(meters);
if (!parts) return '—';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
@@ -43,76 +47,95 @@ function presentAwarenessReason(summary = {}) {
: translateKnown(summary.reason || '');
}
export function createPanel({ state: layerState, services, parts, source }) {
/**
* The Contacts panel as a view renders it. The layer presents a plain view
* object (`standby`, `results` or `empty`) and publishes it to subscribers;
* Core's AwarenessPanel component renders it and calls the layer's
* navigation methods. Text arrives in the UI language.
*/
export function createPanel({ state: layerState, parts }) {
/** Publish a fresh view unless it is the one already shown. */
function publish(view) {
const signature = JSON.stringify(view);
if (layerState.panelSignature === signature) return;
layerState.panelSignature = signature;
layerState.panelView = view;
for (const listener of [...layerState.panelListeners]) listener(view);
}
/**
* Receive every published view, starting with the current one.
* @param {(view: object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribePanel(listener) {
layerState.panelListeners.add(listener);
if (layerState.panelView) listener(layerState.panelView);
return () => layerState.panelListeners.delete(listener);
}
/** The panel shows layer views from the first enable or result onwards. */
function ensurePanel() {
if (layerState.panel) return layerState.panel;
const existing = document.getElementById('military-awareness-panel');
const panel = existing || document.createElement('aside');
layerState.panelOwned = !existing;
if (!existing) {
panel.id = 'military-awareness-panel';
}
layerState.panelClickListener = (event) => {
const action = event.target.closest('button[data-awareness-action]');
if (action) {
event.preventDefault();
if (action.dataset.awarenessAction === 'previous')
parts.history.navigateHistory(-1, { origin: 'user' });
if (action.dataset.awarenessAction === 'next')
parts.history.navigateHistory(1, { origin: 'user' });
if (action.dataset.awarenessAction === 'focus')
parts.focus.focusCurrentSubject({ origin: 'user' });
return;
}
const target = event.target.closest(
'button[data-awareness-layer][data-awareness-id]',
);
if (!target) return;
event.preventDefault();
parts.focus.requestFocus(
target.dataset.awarenessLayer,
target.dataset.awarenessId,
false,
{ origin: 'user' },
);
};
panel.addEventListener('click', layerState.panelClickListener);
if (!existing) document.body.appendChild(panel);
layerState.panel = panel;
return panel;
layerState.panelShown = true;
// A shown view is worded again in a new locale.
layerState.offPanelLocale ??= onLocaleChange(() => {
const kind = layerState.panelView?.kind;
if (kind === 'results') renderResults();
else if (kind === 'standby') hidePanel();
});
}
function hidePanel() {
if (layerState.panel) {
const markup = `<div class="military-awareness-standby">
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
</div>`;
layerState.panel.hidden = false;
if (layerState.panelMarkup !== markup) {
layerState.panel.innerHTML = markup;
layerState.panelMarkup = markup;
}
}
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
if (layerState.panelShown)
publish({
kind: 'standby',
title: layerState.enabled
? t('CONTEXT READY')
: t('GLOBAL CONTEXT OFF'),
text: layerState.enabled
? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION')
: t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'),
});
parts.rendering.hideDirectionOverlay();
}
function rowHtml(cohort) {
/** Clear the panel when the layer is destroyed. */
function clearPanel() {
if (layerState.panelShown) publish({ kind: 'empty' });
layerState.offPanelLocale?.();
layerState.offPanelLocale = null;
layerState.panelShown = false;
layerState.panelView = null;
layerState.panelSignature = '';
}
function presentRow(cohort) {
const summary = cohort.summary;
const count = presentAwarenessCount(summary);
const page = layerState.cohortPages.get(cohort.id) || 0;
const nearest = summary.nearest
.slice(page, page + AWARENESS_PAGE_SIZE)
.map((item) => {
.map((item, index) => {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
if (!targetId) {
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
}
const distance = presentAwarenessDistance(item.distanceM);
if (!targetId)
return {
key: `unavailable:${page + index}`,
label,
distance,
ariaLabel: t('Unavailable'),
};
const accessibleLabel = label === '—' ? t('Unavailable') : label;
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
})
.join('');
return {
key: `target:${cohort.id}:${targetId}`,
layer: cohort.id,
targetId: String(targetId),
label,
distance,
ariaLabel: t('Focus {label}', { label: accessibleLabel }),
names: presentNames(item.memberNames),
};
});
const pageCount = Math.max(
1,
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
@@ -121,109 +144,58 @@ export function createPanel({ state: layerState, services, parts, source }) {
pageCount > 1
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
: '';
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
${nearest ? `<ul>${nearest}</ul>` : ''}
</section>`;
const coverage = cohort.coverage ? ` · ${cohort.coverage}` : '';
return {
id: cohort.id,
relationship: summary.relationship.toLowerCase(),
label: cohort.label,
count: `${presentAwarenessCount(summary)}${pageLabel}`,
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
nearest,
};
}
function navigationControlsHtml() {
const canPrevious = layerState.navigationIndex > 0;
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
</div>`;
function presentNavigation() {
return {
label: t('Global Context navigation'),
previous: {
text: t('PREVIOUS'),
title: t('Previous — prior visited contact in the 250 km window'),
disabled: !(layerState.navigationIndex > 0),
},
focus: { text: t('FOCUS') },
next: {
text: t('NEXT'),
title: t('Next — nearest unvisited contact in the 250 km window'),
disabled: !parts.navigation.canNavigateNext(),
},
};
}
/** Stable identity for a delegated Contacts-panel button across live repaints. */
function awarenessPanelControlKey(element) {
const control = element?.closest?.(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
);
if (!control) return null;
if (control.dataset.awarenessAction)
return `action:${control.dataset.awarenessAction}`;
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
}
/** Capture stable identity so a live repaint can restore only the same control. */
function captureAwarenessPanelFocus(
panel,
activeElement = document.activeElement,
) {
if (!panel?.contains?.(activeElement)) return null;
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
return { key: 'continuation' };
}
const key = awarenessPanelControlKey(activeElement);
if (!key) return null;
return { key };
}
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
function restoreAwarenessPanelFocus(panel, snapshot) {
if (!panel || !snapshot) return null;
const continuation = panel.querySelector(
'[data-awareness-focus-continuation]',
);
const controls = [
...panel.querySelectorAll(
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
),
].filter((control) => !control.disabled);
const retained =
snapshot.key === 'continuation'
? continuation
: controls.find(
(control) => awarenessPanelControlKey(control) === snapshot.key,
);
const target = retained || continuation;
target?.focus?.({ preventScroll: true });
return target || null;
}
function escapeHtml(value) {
return String(value ?? '').replace(
/[&<>'"]/g,
(char) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char],
);
}
function namedAreasHtml(names) {
function presentNames(names) {
return names?.length
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
? {
summary: t('Named areas ({n})', { n: formatNumber(names.length) }),
names: [...names].map((name) => String(name ?? '')),
}
: null;
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
ensurePanel();
layerState.panelRenders += 1;
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
layerState.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
publish({
kind: 'results',
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
subjectNames: presentNames(subject.memberNames),
navigation: presentNavigation(),
rows: cohorts.map(presentRow),
note: t(
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
),
});
}
function rotateAwarenessPages() {
@@ -277,14 +249,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
}
return {
subscribePanel,
ensurePanel,
hidePanel,
rowHtml,
navigationControlsHtml,
awarenessPanelControlKey,
captureAwarenessPanelFocus,
restoreAwarenessPanelFocus,
escapeHtml,
clearPanel,
renderResults,
rotateAwarenessPages,
startAwarenessPageRotation,
+3 -2
View File
@@ -1,3 +1,5 @@
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
@@ -5,6 +7,5 @@
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
+116 -81
View File
@@ -22,55 +22,75 @@ export function createRendering({
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
const { governorRequestRender } = services.render;
const CARDINALS = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
/**
* The direction overlay as its view renders it: the compass ring, its
* cardinals and heading, and up to three contact markers inside the
* viewer's container. Each part keeps its last placement while hidden.
*/
function ensureDirectionOverlay() {
if (layerState.directionRoot || !layerState.viewer)
return layerState.directionRoot;
const root = document.createElement('div');
root.id = 'military-awareness-direction-overlay';
root.hidden = true;
root.setAttribute('aria-hidden', 'true');
const compass = document.createElement('div');
compass.className = 'military-awareness-compass-ring';
const cardinals = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
layerState.compassLabels = cardinals.map(([label, bearing]) => {
const element = document.createElement('span');
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
element.textContent = label;
element.dataset.bearing = String(bearing);
root.appendChild(element);
return element;
if (layerState.directionView || !layerState.viewer)
return layerState.directionView;
presentDirection({
container: layerState.viewer.container,
hidden: true,
ring: null,
heading: { text: '', left: null, top: null },
labels: CARDINALS.map(([label, bearing]) => ({
label,
bearing,
left: null,
top: null,
})),
markers: Array.from({ length: 3 }, () => ({
hidden: false,
style: null,
arrow: null,
caption: { text: '', style: null },
})),
});
const heading = document.createElement('span');
heading.className = 'military-awareness-compass-heading';
root.append(compass, heading);
return layerState.directionView;
}
layerState.directionMarkers = Array.from({ length: 3 }, () => {
const marker = document.createElement('div');
marker.className = 'military-awareness-direction-marker';
const arrow = document.createElement('span');
arrow.className = 'military-awareness-direction-arrow';
arrow.textContent = '➜';
const label = document.createElement('span');
label.className = 'military-awareness-direction-label';
marker.append(arrow, label);
marker._arrow = arrow;
marker._label = label;
marker._lastAngle = 0;
root.appendChild(marker);
return marker;
});
/** Publish a change to the overlay, skipping one that changes nothing. */
function presentDirection(patch) {
const next = patch && { ...layerState.directionView, ...patch };
const signature = JSON.stringify(next, (name, value) =>
name === 'container' ? undefined : value,
);
if (next && signature === layerState.directionSignature) return;
layerState.directionSignature = next ? signature : '';
layerState.directionView = next && Object.freeze(next);
for (const listener of [...layerState.directionListeners])
listener(layerState.directionView);
}
layerState.compassRing = compass;
layerState.compassHeading = heading;
layerState.viewer.container.appendChild(root);
layerState.directionRoot = root;
return root;
/**
* Receive the direction overlay now and after every change.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribeDirectionOverlay(listener) {
layerState.directionListeners.add(listener);
listener(layerState.directionView);
return () => layerState.directionListeners.delete(listener);
}
/** Hide the overlay, keeping its last placement. */
function hideDirectionOverlay() {
if (layerState.directionView) presentDirection({ hidden: true });
}
/** Withdraw the overlay when the layer is destroyed. */
function removeDirectionOverlay() {
if (layerState.directionView) presentDirection(null);
layerState.directionAngles = [0, 0, 0];
}
function scheduleDirectionOverlayUpdate(force = false) {
@@ -104,7 +124,7 @@ export function createRendering({
!layerState.subject?.position ||
!layerState.results
) {
if (root) root.hidden = true;
if (root) presentDirection({ hidden: true });
return;
}
@@ -116,41 +136,43 @@ export function createRendering({
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
presentDirection({ hidden: true });
return;
}
root.hidden = false;
// Keep the compass comfortably inside the shader keyhole so its cardinals
// remain visible around the subject instead of hiding under edge panels.
const compassRadius = Math.max(
120,
Math.min(geometry.radius - 48, geometry.radius * 0.66),
);
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
layerState.compassRing.style.width = `${compassRadius * 2}px`;
layerState.compassRing.style.height = `${compassRadius * 2}px`;
const cameraHeading = layerState.viewer.camera.heading || 0;
const headingDeg =
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
layerState.compassRing.style.setProperty(
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
const ring = {
left: `${geometry.centerX - compassRadius}px`,
top: `${geometry.centerY - compassRadius}px`,
width: `${compassRadius * 2}px`,
height: `${compassRadius * 2}px`,
'--compass-rotation': `${-headingDeg}deg`,
};
const heading = {
text: t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
}),
left: `${geometry.centerX}px`,
top: `${geometry.centerY - compassRadius + 39}px`,
};
const labels = root.labels.map((label) => {
const bearing = Cesium.Math.toRadians(label.bearing);
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
const labelRadius = compassRadius - 17;
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
}
return {
...label,
left: `${geometry.centerX + Math.cos(angle) * labelRadius}px`,
top: `${geometry.centerY + Math.sin(angle) * labelRadius}px`,
};
});
const directionalCohort =
layerState.results.cohorts.find(
@@ -163,8 +185,9 @@ export function createRendering({
page,
page + AWARENESS_PAGE_SIZE,
) || [];
for (let index = 0; index < layerState.directionMarkers.length; index++) {
const marker = layerState.directionMarkers[index];
const markers = root.markers.map((marker) => ({ ...marker }));
for (let index = 0; index < markers.length; index++) {
const marker = markers[index];
const item = military[index];
if (!item?.position) {
marker.hidden = true;
@@ -183,27 +206,32 @@ export function createRendering({
const projection = celestialScreenAngle(
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
marker._lastAngle,
layerState.directionAngles[index],
);
if (projection.stable) marker._lastAngle = projection.angle;
if (projection.stable)
layerState.directionAngles[index] = projection.angle;
// Every detected contact shares the compass rim. Keeping the bearing
// markers on one radius makes them read as compass contacts rather than
// unconstrained labels floating over the map.
const markerRadius = compassRadius;
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
marker.style = {
left: `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`,
top: `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`,
opacity: String(projection.opacity),
};
const sonarFactor =
sonar?.at(
geometry.centerX + Math.cos(projection.angle) * markerRadius,
geometry.centerY + Math.sin(projection.angle) * markerRadius,
) ?? 1;
marker._arrow.style.opacity = String(sonarFactor);
marker._label.style.opacity = String(
sonar ? sonar.label(sonarFactor) : 1,
);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
marker.arrow = {
opacity: String(sonarFactor),
transform: `translate(-50%, -50%) rotate(${projection.angle}rad)`,
};
const labelStyle = {
opacity: String(sonar ? sonar.label(sonarFactor) : 1),
transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
};
const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian(
layerState.subject.position,
@@ -234,9 +262,13 @@ export function createRendering({
value: formatAwarenessDirection(item.track),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.caption = {
text: `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`,
style: labelStyle,
};
marker.hidden = false;
}
presentDirection({ hidden: false, ring, heading, labels, markers });
}
function clearVisual() {
@@ -303,6 +335,9 @@ export function createRendering({
}
return {
ensureDirectionOverlay,
subscribeDirectionOverlay,
hideDirectionOverlay,
removeDirectionOverlay,
scheduleDirectionOverlayUpdate,
cancelDirectionOverlayUpdate,
updateDirectionOverlay,
+14 -9
View File
@@ -29,20 +29,26 @@ export function createState({ services }) {
subject: null,
results: null,
visual: null,
panel: null,
panelOwned: false,
/** Whether layer views replace the panel's initial markup yet. */
panelShown: false,
/** The published panel view, its signature and its subscribers. */
panelView: null,
panelSignature: '',
panelListeners: new Set(),
/** Result renders, published or not; tests observe repaint storms. */
panelRenders: 0,
subjectListener: null,
contextListener: null,
clearListener: null,
subjectClearListener: null,
runtimeListenersAttached: false,
preRenderRemover: null,
panelClickListener: null,
directionRoot: null,
compassRing: null,
compassLabels: [],
compassHeading: null,
directionMarkers: [],
/** The published direction overlay, its signature and its subscribers. */
directionView: null,
directionSignature: '',
directionListeners: new Set(),
/** Each marker's last stable screen angle. */
directionAngles: [0, 0, 0],
navigationHistory: [],
navigationVisited: new Set(),
navigationIndex: -1,
@@ -57,7 +63,6 @@ export function createState({ services }) {
cameraMoving: false,
lastEvaluatedPosition: null,
sourceRevision: '',
panelMarkup: '',
directionFrame: null,
lastDirectionUpdateMs: 0,
activationId: 0,
+8 -1
View File
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
pendingSelectionKey: layerState.pendingSelectionKey,
};
}
return { _getAwarenessNavigationStateForTest };
/** @returns {number} How many result renders ran, published or not. */
function _getAwarenessPanelRendersForTest() {
return layerState.panelRenders;
}
return {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
};
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* @module headingConfidence
@@ -44,7 +45,7 @@ export function isHeadingEstimated(camera) {
* @returns {string}
*/
export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0);
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
@@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => {
);
});
test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => {
assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°');
assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°');
assert.equal(
headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }),
'HDG 0° (ESTIMATED)',
);
});
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
assert.equal(
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
+4 -1
View File
@@ -1,5 +1,5 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { msg, t } from '../../i18n/index.js';
import { isolate, msg, t } from '../../i18n/index.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import {
@@ -97,6 +97,8 @@ export function createPresentation({
const health = layerState._healthById.get(active.camera.id) || null;
const calBadge = parts.calibration.deriveCalBadge(active.camera);
// Each part is isolated: in right-to-left text a code keeps its own value
// (FOV 44°) instead of trading it with its neighbour.
return [
t('{city} CCTV', {
city: active.camera.city.toUpperCase(),
@@ -140,6 +142,7 @@ export function createPresentation({
: null,
]
.filter(Boolean)
.map(isolate)
.join(' · ');
}
+4 -1
View File
@@ -16,6 +16,7 @@ import {
} from './labels.js';
import {
formatUtcDayMonth,
isolate,
t,
tn,
msg,
@@ -25,9 +26,11 @@ import { formatQuantity } from '../../ui/unitLabels.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
// The clock is isolated (a no-op outside RTL): a no-break space between two
// numbers joins them into one run, which in Arabic split the date around it.
const utc = (value) =>
value
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${isolate(value.slice(11, 16))}\u00a0UTC`
: t('Unavailable');
const COVERAGE = msg(
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
+7 -11
View File
@@ -31,6 +31,7 @@ import {
quantitySeparator,
unitLabel,
} from '../../ui/unitLabels.js';
import { distanceParts } from '../../data/distanceFormat.js';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -108,17 +109,12 @@ export const FLIGHT_PROGRESS_MS = 250;
* @returns {string}
*/
export function routeDistanceText(meters) {
if (!Number.isFinite(meters) || meters < 0) return '';
if (meters < 100) return formatQuantity(Math.round(meters), 'm');
const tens = Math.round(meters / 10) * 10;
if (tens < 1000) return formatQuantity(tens, 'm');
const km = meters / 1000;
return km < 10
? formatQuantity(km, 'km', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
})
: formatQuantity(Math.round(km), 'km');
const parts = distanceParts(meters, { metreStep: 10, exactBelowM: 100 });
if (!parts) return '';
return formatQuantity(parts.value, parts.unit, {
minimumFractionDigits: parts.digits,
maximumFractionDigits: parts.digits,
});
}
/**
+8 -4
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, t } from '../../i18n/index.js';
import { formatNumber, getLocale, isolate, t, tn } from '../../i18n/index.js';
import {
satelliteShortName,
fireDetectionKey,
@@ -407,7 +407,6 @@ export function createModel({
function buildCellCard(candidate, nowMs) {
const cell = candidate.cell;
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
if (cell.newestAcqMs > 0) {
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
@@ -419,7 +418,12 @@ export function createModel({
cullPosition: candidate.cullPosition || candidate.position,
gapPx: 10,
accent: candidate.accent || accentForSeverity('yellow'),
title: `${cell.count} ${noun}`,
// The count picks the plural form of the language ("حريقان (2)",
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
// other card titles.
title: tn(cell.count, '{n} fire', '{n} fires', {
n: cell.count,
}).toLocaleUpperCase(getLocale()),
details: [parts.join(' · ')],
selected: false,
priority: Number(cell.maxFrp) || 0,
@@ -513,7 +517,7 @@ export function createModel({
lon >= 0
? t('{value}°E', { value: amount(lon) })
: t('{value}°W', { value: amount(lon) });
return `${latPart} ${lonPart}`;
return `${isolate(latPart)} ${isolate(lonPart)}`;
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
+22
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { createCivilFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createEnrichment } from './enrichment.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
await new Promise((done) => setImmediate(done));
assert.equal(flightState._enrichActive, 0);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: services(),
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, altitudeM: 9000 }),
_contactLabel: () => 'AAL1',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('abc123').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -24,6 +24,7 @@ import {
TRAIL_COLOR,
CYAN_TRANSPARENT,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -126,7 +127,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
route: route || '',
icao24,
+9 -2
View File
@@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
windowMs: requestedWindowMs,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
// A source may set its own evidence window; anything but a positive finite
// duration keeps the default.
const windowMs =
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
? requestedWindowMs
: GNSS_WINDOW_MS;
let _viewer = null;
let _primitives = null;
let _request = null;
@@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({
chips: [],
info:
t(
'Regional sample · 250 nm radius · cells from visited views over 30 min',
'Regional sample · 250 nm radius · cells from visited views over {minutes} min',
{ minutes: formatNumber(provenance.window.minutes) },
) +
(_lastError && _retryAt
? ` · ${translateFeedError(_lastError)} · ${
+3 -4
View File
@@ -42,7 +42,10 @@ export function createRocketLaunchesLayer({ services, source }) {
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
parts.panel.commands,
{
subscribePanel: parts.panel.subscribePanel,
subscribeReplayOverlay: parts.overlays.subscribeReplayOverlay,
satelliteParamsForSpaceMissions:
parts.policyHelpers.satelliteParamsForSpaceMissions,
satelliteParamsAfterSpaceMissions:
@@ -69,10 +72,6 @@ export function createRocketLaunchesLayer({ services, source }) {
missionHoverPreviewRange: parts.panel.missionHoverPreviewRange,
createMissionRosterPreviewOwnership:
parts.panel.createMissionRosterPreviewOwnership,
bindMissionRosterItemKeyboardPreview:
parts.panel.bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus: parts.panel.captureMissionRosterFocus,
restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch:
parts.panel.resolveMissionRosterPreviewLaunch,
formatMissionEventTime: parts.policyHelpers.formatMissionEventTime,
+1
View File
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
clearMissionPanel: noop,
},
replay: { stopMissionReplay: noop },
selection: {
+1 -8
View File
@@ -201,7 +201,6 @@ export function createLifecycle({
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer);
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
layerState._selectedLaunchId,
@@ -262,13 +261,7 @@ export function createLifecycle({
layerState._animationStarts.clear();
layerState._satelliteTelemetry.clear();
layerState._replayTracks.clear();
layerState._missionPanel?.remove();
layerState._missionPanel = null;
if (layerState._missionRoster) {
layerState._missionRoster.onclick = null;
}
layerState._missionRosterPreviewOwnership = null;
layerState._missionRoster = null;
parts.panel.clearMissionPanel();
layerState._viewer = null;
if (layerState._dataSource)
viewer.dataSources.remove(layerState._dataSource, true);
@@ -13,20 +13,7 @@ import { createPanel } from './panel.js';
const NOW = new Date('2026-07-27T00:00:00Z');
function shownStatus(status) {
const outputs = new Map();
const state = {
_missionPanel: {
querySelector(selector) {
if (!outputs.has(selector))
outputs.set(selector, {
textContent: '',
closest: () => null,
removeAttribute() {},
});
return outputs.get(selector);
},
closest: () => null,
},
_selectedLaunchId: 'mission-1',
_launches: [
{
@@ -39,15 +26,14 @@ function shownStatus(status) {
],
_replayTracks: new Map(),
};
createPanel({
return createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
replay: { presentReplay: () => ({}) },
},
}).renderMissionPanel();
return outputs.get('[data-mission-status]').textContent;
}).presentMission().status;
}
test('a launch without a status keeps the English fallback in its record', () => {
+62 -77
View File
@@ -280,60 +280,64 @@ export function createOverlays({ state: layerState, services, parts, source }) {
return ascending ? 'ascent' : 'orbit';
}
function createReplayVehicleOverlay() {
if (layerState._replayVehicleOverlay || typeof document === 'undefined')
/**
* Present the replay vehicle overlay to its subscribers, or null to remove
* it. An unchanged presentation is not sent again.
*/
function presentReplayVehicleOverlay(patch) {
const current = layerState._replayVehicleOverlay;
const next = patch && { ...current, ...patch };
if (
next === current ||
(next &&
current &&
Object.keys(next).every((k) => next[k] === current[k]))
)
return;
const host = document.getElementById('cesiumContainer') || document.body;
layerState._replayVehicleOverlay = document.createElement('div');
layerState._replayVehicleOverlay.className =
'mission-replay-vehicle-overlay';
layerState._replayVehicleOverlay.hidden = true;
layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true');
layerState._replayVehicleOverlay.innerHTML = `
<div class="mission-replay-flight-symbol">
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
</svg>
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
</svg>
</div>
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
<div class="mission-replay-overlay-callout">
<strong data-replay-overlay-title></strong>
<span data-replay-overlay-detail></span>
</div>`;
host.appendChild(layerState._replayVehicleOverlay);
layerState._replayVehicleOverlay._sonarIcons = [
...layerState._replayVehicleOverlay.querySelectorAll(
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
),
];
layerState._replayVehicleOverlay._sonarLabel =
layerState._replayVehicleOverlay.querySelector(
'.mission-replay-overlay-callout',
);
layerState._replayVehicleOverlay = next;
for (const listener of [...layerState._replayVehicleOverlayListeners])
listener(next);
}
/**
* Receive every presentation of the replay vehicle overlay, starting with
* the current one.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribeReplayOverlay(listener) {
layerState._replayVehicleOverlayListeners.add(listener);
listener(layerState._replayVehicleOverlay);
return () => layerState._replayVehicleOverlayListeners.delete(listener);
}
function createReplayVehicleOverlay() {
if (layerState._replayVehicleOverlay) return;
presentReplayVehicleOverlay({
visible: false,
mode: null,
thrusting: false,
paused: false,
transform: null,
rotation: null,
iconOpacity: null,
labelOpacity: null,
title: '',
detail: '',
});
}
function hideReplayVehicleOverlay() {
if (!layerState._replayVehicleOverlay) return;
layerState._replayVehicleOverlay.hidden = true;
layerState._replayVehicleOverlay.classList.remove(
'is-thrusting',
'is-paused',
);
presentReplayVehicleOverlay({
visible: false,
thrusting: false,
paused: false,
});
}
function destroyReplayVehicleOverlay() {
layerState._replayVehicleOverlay?.remove();
layerState._replayVehicleOverlay = null;
layerState._replayVehicleOverlayText = '';
presentReplayVehicleOverlay(null);
}
function clearMissionOverlaySources() {
@@ -523,23 +527,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
: null;
const factor =
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
icon.style.opacity = String(factor);
if (layerState._replayVehicleOverlay._sonarLabel)
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
sonar ? sonar.label(factor) : 1,
);
layerState._replayVehicleOverlay.hidden = false;
layerState._replayVehicleOverlay.dataset.mode = mode;
layerState._replayVehicleOverlay.classList.toggle(
'is-thrusting',
replayActive && state.ascending && !state.countdownActive,
);
layerState._replayVehicleOverlay.classList.toggle(
'is-paused',
replayActive && layerState._replayPaused,
);
layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`;
const frame = {
visible: true,
mode,
thrusting: replayActive && state.ascending && !state.countdownActive,
paused: replayActive && layerState._replayPaused,
transform: `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`,
iconOpacity: String(factor),
labelOpacity: String(sonar ? sonar.label(factor) : 1),
};
let vehicleRotation = 0;
if (replayActive && !state.countdownActive) {
const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1));
@@ -581,10 +577,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
}
}
}
layerState._replayVehicleOverlay.style.setProperty(
'--mission-replay-rotation',
`${Cesium.Math.toDegrees(vehicleRotation)}deg`,
);
frame.rotation = `${Cesium.Math.toDegrees(vehicleRotation)}deg`;
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
@@ -620,16 +613,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
layerState._replayVehicleOverlay.querySelector(
'[data-replay-overlay-title]',
).textContent = title;
layerState._replayVehicleOverlay.querySelector(
'[data-replay-overlay-detail]',
).textContent = detail;
}
presentReplayVehicleOverlay({ ...frame, title, detail });
return mode;
}
return {
@@ -643,6 +627,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
missionHoverReticleImage,
replayOverlayMode,
createReplayVehicleOverlay,
subscribeReplayOverlay,
hideReplayVehicleOverlay,
destroyReplayVehicleOverlay,
clearMissionOverlaySources,
+280 -363
View File
@@ -1,11 +1,20 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import { formatNumber, isolate, onLocaleChange, t } from '../../i18n/index.js';
import {
MISSION_CLOSE_VIEW_RANGE_M,
MISSION_GLOBE_VIEW_RANGE_M,
} from './policy.js';
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
// Landing endpoints carry an English accuracy code (the panel and the map
// entity compare it); only the text shown in the stage table is translated.
// The downrange estimate arrives already translated.
function accuracyLabel(accuracy) {
if (accuracy === 'CONFIRMED') return t('CONFIRMED');
if (accuracy === 'PAD / RTLS') return t('PAD / RTLS');
return accuracy;
}
export function createPanel({ state: layerState, services, parts, source }) {
/**
* Preserve the user's globe scale for roster previews while avoiding an
@@ -88,68 +97,6 @@ export function createPanel({ state: layerState, services, parts, source }) {
};
}
/**
* Bind native keyboard focus events for one rendered roster row.
* @param {Element} button Mission roster button.
* @param {number} index Source-array mission index.
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
* @param {Element} roster Roster root used to reject focus targets outside the list.
*/
function bindMissionRosterItemKeyboardPreview(
button,
index,
ownership,
roster,
) {
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
button.addEventListener('mouseleave', () => ownership.pointerLeave());
button.addEventListener('focus', () => ownership.focus(index));
button.addEventListener('blur', (event) => {
const nextButton = event.relatedTarget?.closest?.(
'[data-mission-roster-index]',
);
const nextIndex =
nextButton && roster.contains(nextButton)
? Number(nextButton.dataset.missionRosterIndex)
: null;
ownership.blur(nextIndex);
});
}
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
function captureMissionRosterFocus(
list,
activeElement = globalThis.document?.activeElement,
) {
const button = activeElement?.closest?.('[data-mission-roster-id]');
if (!button || !list?.contains(button)) return null;
const launchId = button.dataset.missionRosterId;
return launchId ? { launchId } : null;
}
/** Restore focus by mission identity, or continue after the list if it departed. */
function restoreMissionRosterFocus(list, snapshot, continuation) {
if (!snapshot?.launchId) return 'none';
const button = Array.from(
list?.querySelectorAll?.('[data-mission-roster-id]') || [],
).find(
(candidate) => candidate.dataset.missionRosterId === snapshot.launchId,
);
if (button) {
button.focus({ preventScroll: true });
return 'restored';
}
if (continuation?.focus) {
continuation.focus({ preventScroll: true });
return 'continued';
}
return 'none';
}
/** Resolve a pending preview against the current refresh, never a stale object. */
function resolveMissionRosterPreviewLaunch(launches, launchId) {
@@ -158,213 +105,213 @@ export function createPanel({ state: layerState, services, parts, source }) {
);
}
function missionTableRows(items, columns, emptyText) {
if (!items.length)
return `<tr><td colspan="${columns}" class="mission-table-empty">${emptyText}</td></tr>`;
return items.map((item) => item).join('');
/**
* Publish a fresh view unless it is the one already shown. The roster
* carries its own signature, so a telemetry update compares the mission.
*/
function publish(view) {
const signature = `${layerState._missionRosterSignature}|${JSON.stringify(view.mission)}`;
if (layerState._missionPanelSignature === signature) return;
layerState._missionPanelSignature = signature;
layerState._missionPanelView = view;
for (const listener of [...layerState._missionPanelListeners])
listener(view);
}
function setMissionPanelField(selector, value, title = '') {
const output = layerState._missionPanel?.querySelector(selector);
if (!output) return;
const row = output.closest('[data-mission-field]');
const available =
value !== null && value !== undefined && String(value).trim() !== '';
if (row) row.hidden = !available;
if (!available) {
output.textContent = '';
output.removeAttribute('title');
return;
}
output.textContent = value;
if (title) output.title = title;
else output.removeAttribute('title');
/**
* Receive every published view, starting with the current one.
* @param {(view: {roster: ?object, mission: ?object}) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribePanel(listener) {
layerState._missionPanelListeners.add(listener);
if (layerState._missionPanelView) listener(layerState._missionPanelView);
return () => layerState._missionPanelListeners.delete(listener);
}
function renderMissionPanel() {
if (!layerState._missionPanel) return;
/** Publish the roster and the selected mission as they stand. */
function publishPanel() {
if (!layerState._missionPanelReady) return;
publish({
roster: layerState._missionRosterView,
mission: presentMission(),
});
}
/** A detail value, or null when the panel hides its row. */
function presentField(value) {
return value !== null && value !== undefined && String(value).trim() !== ''
? String(value)
: null;
}
function presentPayload(payload, launch) {
const detail = [
payload.manufacturer,
payload.operator && payload.operator !== payload.manufacturer
? payload.operator
: null,
Number.isFinite(payload.massKg)
? `${formatNumber(payload.massKg)} KG`
: null,
]
.filter(Boolean)
.join(' · ');
return {
name: `${payload.name ?? ''}${payload.amount > 1 ? ` ×${payload.amount}` : ''}`,
detail,
type: payload.type || t('UNSPECIFIED'),
destination:
payload.destination || launch.orbit?.name || t('UNAVAILABLE'),
};
}
function presentStage(stage, launch) {
const endpoint = stage.endpoint;
const destination =
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
? launch.launchSite
: t('UNAVAILABLE'));
const position = endpoint
? `${isolate(`${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)}`)} · ${accuracyLabel(endpoint.accuracy)}`
: stage.downrangeKm > 0
? t('{value} KM DOWNRANGE', {
value: formatNumber(stage.downrangeKm),
})
: t('POSITION UNAVAILABLE');
const detail = [
Number.isFinite(stage.flightNumber)
? t('FLIGHT {count}', {
count: stage.flightNumber,
})
: null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
.join(' · ');
return {
name: String(stage.name ?? ''),
detail,
status: String(stage.status ?? ''),
destination: String(destination ?? ''),
position,
};
}
/**
* The selected mission as the panel shows it, or null when none is
* selected and the panel hides. Text arrives in the UI language.
*/
function presentMission() {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
const index = launch ? layerState._launches.indexOf(launch) : -1;
layerState._missionPanel.hidden = !launch;
if (!launch) return;
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
setMissionPanelField('[data-mission-provider]', launch.provider);
// Launch Library status names are provider data, shown as sent; only the
// fallback for a launch without one is ours to translate.
setMissionPanelField(
'[data-mission-status]',
launch.status === UNKNOWN_LAUNCH_STATUS
? t(UNKNOWN_LAUNCH_STATUS)
: launch.status,
);
setMissionPanelField(
'[data-mission-site]',
launch.launchSite && launch.launchSite !== 'Unknown launch site'
? launch.launchSite
: null,
);
setMissionPanelField('[data-mission-time]', launch.launchTime);
if (!launch) return null;
const index = layerState._launches.indexOf(launch);
const pathPresentation = parts.policyHelpers.missionPathPresentation(
launch,
layerState._replayTracks.has(launch.id),
);
setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit);
layerState._missionPanel.querySelector(
'[data-mission-ascent-source]',
).textContent = pathPresentation.ascent;
const payloadRows = launch.payloads.length
? launch.payloads.slice(0, 5).map((payload) => {
const detail = [
payload.manufacturer,
payload.operator && payload.operator !== payload.manufacturer
? payload.operator
: null,
Number.isFinite(payload.massKg)
? `${payload.massKg.toLocaleString()} KG`
: null,
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
})
: [];
if (launch.payloads.length > 5) {
payloadRows.push(
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
);
}
layerState._missionPanel.querySelector(
'[data-mission-payloads]',
).innerHTML = missionTableRows(
payloadRows,
3,
t('PAYLOAD DATA UNAVAILABLE'),
);
const stageRows = launch.recoveryStages.map((stage) => {
const endpoint = stage.endpoint;
const destination =
stage.destination ||
(endpoint?.accuracy === 'PAD / RTLS'
const telemetry = layerState._missionTelemetry;
return {
scrollRequest: layerState._missionPanelScrollRequest,
title: parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(),
provider: presentField(launch.provider),
// Launch Library status names are provider data, shown as sent; only
// the fallback for a launch without one is ours to translate.
status: presentField(
launch.status === UNKNOWN_LAUNCH_STATUS
? t(UNKNOWN_LAUNCH_STATUS)
: launch.status,
),
site: presentField(
launch.launchSite && launch.launchSite !== 'Unknown launch site'
? launch.launchSite
: t('UNAVAILABLE'));
const position = endpoint
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
: stage.downrangeKm > 0
? t('{value} KM DOWNRANGE', {
value: stage.downrangeKm.toLocaleString(),
})
: t('POSITION UNAVAILABLE');
const stageDetail = [
Number.isFinite(stage.flightNumber)
? t('FLIGHT {count}', {
count: stage.flightNumber,
: null,
),
time: presentField(launch.launchTime),
orbit: presentField(pathPresentation.orbit),
ascent: pathPresentation.ascent ?? '',
distance: telemetry?.distance ?? null,
speed: telemetry?.speed ?? null,
speedTitle: telemetry?.speedTitle ?? null,
payloads: launch.payloads
.slice(0, 5)
.map((payload) => presentPayload(payload, launch)),
payloadsMore:
launch.payloads.length > 5
? t('+{count} additional payload records', {
count: launch.payloads.length - 5,
})
: null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
if (stageSection) stageSection.hidden = stageRows.length === 0;
updateMissionTelemetry(true);
layerState._missionPanel.querySelector('[data-mission-index]').textContent =
`${index + 1} / ${layerState._launches.length}`;
layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
index <= 0;
layerState._missionPanel.querySelector('[data-mission-next]').disabled =
index < 0 || index >= layerState._launches.length - 1;
parts.replay.syncReplayButton();
const panelScroller = layerState._missionPanel.closest(
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
);
if (panelScroller) panelScroller.scrollTop = 0;
stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
index: `${index + 1} / ${layerState._launches.length}`,
previousDisabled: index <= 0,
nextDisabled: index < 0 || index >= layerState._launches.length - 1,
replay: parts.replay.presentReplay(),
};
}
function renderMissionRoster() {
if (!layerState._missionRoster) return;
const list = layerState._missionRoster.querySelector(
'[data-mission-roster-list]',
);
const count = layerState._missionRoster.querySelector(
'[data-mission-roster-count]',
);
if (count)
count.textContent = t('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
layerState._missionRosterPreviewOwnership?.reset();
const entries = parts.policyHelpers.missionRosterEntries(
layerState._launches,
);
if (!entries.length) {
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
layerState._missionRoster.querySelector(
'[data-mission-roster-focus-continuation]',
),
);
return;
}
list.innerHTML = entries
/** Present the newly selected mission and return its panel to the top. */
function renderMissionPanel() {
if (!layerState._missionPanelReady) return;
layerState._missionTelemetry = null;
updateMissionTelemetry(true, false);
layerState._missionPanelScrollRequest++;
publishPanel();
}
/** The roster entries as the list shows them. */
function presentRoster() {
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
const index = Number(button.dataset.missionRosterIndex);
bindMissionRosterItemKeyboardPreview(
button,
index,
layerState._missionRosterPreviewOwnership,
layerState._missionRoster,
);
});
restoreMissionRosterFocus(
list,
focusSnapshot,
layerState._missionRoster.querySelector(
'[data-mission-roster-focus-continuation]',
),
);
return {
index,
id: String(launch.id ?? ''),
color: parts.model.missionMarkerColor(launch).toCssColorString(),
label,
ariaLabel: t('Select {mission}', { mission: label }),
provider: launch.provider || t('UNSPECIFIED OPERATOR'),
date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
};
});
return {
count: t('{count} / 30D', { count: layerState._launches.length }),
entries,
empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
};
}
function escapeMissionText(value) {
return String(value ?? '').replace(
/[&<>"']/g,
(character) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character],
);
/**
* Present the roster. A changed roster drops the hover and keyboard
* preview; the list restores keyboard focus by mission identity, which
* resumes the preview.
*/
function renderMissionRoster() {
if (!layerState._missionPanelReady) return;
const roster = presentRoster();
const signature = JSON.stringify(roster);
if (signature !== layerState._missionRosterSignature) {
layerState._missionRosterSignature = signature;
layerState._missionRosterView = roster;
layerState._missionRosterPreviewOwnership?.reset();
}
publishPanel();
}
function updateMissionTelemetry(force = false) {
/**
* Read the selected mission's altitude and speed, at most four times a
* second unless forced, and republish when they change.
*/
function updateMissionTelemetry(force = false, republish = true) {
if (
!layerState._missionPanel ||
!layerState._missionPanelReady ||
!layerState._selectedLaunchId ||
!layerState._dataSource
)
@@ -381,33 +328,30 @@ export function createPanel({ state: layerState, services, parts, source }) {
const altitudeM = position
? Cesium.Cartographic.fromCartesian(position)?.height
: null;
setMissionPanelField(
'[data-mission-distance]',
Number.isFinite(altitudeM)
const speedMps = layerState._satelliteTelemetry.get(
layerState._selectedLaunchId,
)?.speedMps;
layerState._missionTelemetry = {
distance: Number.isFinite(altitudeM)
? t('{value} KM', {
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
value: formatNumber(Math.max(0, altitudeM / 1000), {
maximumFractionDigits: 0,
}),
})
: null,
);
const speedMps = layerState._satelliteTelemetry.get(
layerState._selectedLaunchId,
)?.speedMps;
setMissionPanelField(
'[data-mission-speed]',
Number.isFinite(speedMps)
speed: Number.isFinite(speedMps)
? t('{value} KM/S', {
value: (speedMps / 1000).toLocaleString(undefined, {
value: formatNumber(speedMps / 1000, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})
: null,
Number.isFinite(speedMps)
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
: '',
);
speedTitle: Number.isFinite(speedMps)
? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h`
: null,
};
if (republish) publishPanel();
}
function selectMissionAt(index) {
@@ -451,121 +395,94 @@ export function createPanel({ state: layerState, services, parts, source }) {
}, 140);
}
function createMissionPanel() {
if (layerState._missionPanel || typeof document === 'undefined') return;
const host =
document.getElementById('space-mission-panel-host') ||
document.getElementById('right-context-rail');
if (!host) return;
layerState._missionRoster = document.getElementById('space-mission-roster');
if (layerState._missionRoster) {
layerState._missionRosterPreviewOwnership =
createMissionRosterPreviewOwnership({
preview: scheduleMissionRosterPreview,
clear: clearMissionRosterPreviewState,
});
layerState._missionRoster.onclick = (event) => {
const button =
event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
: null;
if (!button) return;
selectMissionAt(Number(button.dataset.missionRosterIndex));
};
}
layerState._missionPanel = document.createElement('aside');
layerState._missionPanel.id = 'space-mission-panel';
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
t('Selected Space Mission'),
/** Select the mission before or after the selected one. */
function selectAdjacentMission(step) {
selectMissionAt(
layerState._launches.findIndex(
(item) => item.id === layerState._selectedLaunchId,
) + step,
);
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
layerState._missionPanel
.querySelector('.mission-action-row')
.insertAdjacentHTML(
'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
</div>`,
);
host.appendChild(layerState._missionPanel);
layerState._missionPanel
.querySelector('[data-mission-prev]')
.addEventListener('click', () =>
selectMissionAt(
layerState._launches.findIndex(
(item) => item.id === layerState._selectedLaunchId,
) - 1,
),
);
layerState._missionPanel
.querySelector('[data-mission-next]')
.addEventListener('click', () =>
selectMissionAt(
layerState._launches.findIndex(
(item) => item.id === layerState._selectedLaunchId,
) + 1,
),
);
layerState._missionPanel
.querySelector('[data-mission-close]')
.addEventListener('click', () =>
parts.selection.setSelectedMission(null),
);
layerState._missionPanel
.querySelector('[data-mission-show-all]')
.addEventListener('click', () =>
parts.selection.setSelectedMission(null),
);
layerState._missionPanel
.querySelector('[data-mission-focus]')
.addEventListener('click', () => {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
if (launch) parts.selection.focusLaunchSite(launch);
});
layerState._missionPanel
.querySelector('[data-mission-replay]')
.addEventListener('click', () => {
if (layerState._selectedLaunchId)
parts.replay.startMissionReplay(layerState._selectedLaunchId);
});
layerState._missionPanel
.querySelector('[data-mission-replay-toggle]')
.addEventListener('click', () => {
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
else parts.replay.pauseMissionReplay();
});
layerState._missionPanel
.querySelector('[data-mission-replay-cancel]')
.addEventListener('click', parts.replay.stopMissionReplay);
layerState._missionPanel
.querySelector('[data-mission-replay-speed]')
.addEventListener('input', (event) => {
parts.replay.setReplaySpeed(event.currentTarget.value);
});
parts.replay.syncReplaySpeedControl();
}
/** The panel's commands, called by Core's mission components. */
const commands = {
selectMissionAt,
selectPreviousMission: () => selectAdjacentMission(-1),
selectNextMission: () => selectAdjacentMission(1),
deselectMission: () => parts.selection.setSelectedMission(null),
focusSelectedLaunchSite() {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
if (launch) parts.selection.focusLaunchSite(launch);
},
startSelectedReplay() {
if (layerState._selectedLaunchId)
parts.replay.startMissionReplay(layerState._selectedLaunchId);
},
toggleReplayPause() {
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
else parts.replay.pauseMissionReplay();
},
cancelReplay: () => parts.replay.stopMissionReplay(),
setReplaySpeed: (value) => parts.replay.setReplaySpeed(value),
rosterPointerEnter: (index) =>
layerState._missionRosterPreviewOwnership?.pointerEnter(index),
rosterPointerLeave: () =>
layerState._missionRosterPreviewOwnership?.pointerLeave(),
rosterFocus: (index) =>
layerState._missionRosterPreviewOwnership?.focus(index),
rosterBlur: (nextIndex) =>
layerState._missionRosterPreviewOwnership?.blur(nextIndex),
};
/** Start presenting the roster and the selected mission. */
function createMissionPanel() {
if (layerState._missionPanelReady) return;
layerState._missionPanelReady = true;
layerState._missionRosterPreviewOwnership =
createMissionRosterPreviewOwnership({
preview: scheduleMissionRosterPreview,
clear: clearMissionRosterPreviewState,
});
// The roster and mission are worded again in a new locale.
layerState._offMissionLocale = onLocaleChange(() => {
renderMissionRoster();
updateMissionTelemetry(true, false);
publishPanel();
});
publishPanel();
}
/** Stop presenting; the components return to their initial markup. */
function clearMissionPanel() {
layerState._missionPanelReady = false;
layerState._offMissionLocale?.();
layerState._offMissionLocale = null;
layerState._missionRosterPreviewOwnership = null;
layerState._missionRosterView = null;
layerState._missionRosterSignature = null;
layerState._missionTelemetry = null;
layerState._replayTransportLabel = null;
publish({ roster: null, mission: null });
}
return {
missionHoverPreviewRange,
createMissionRosterPreviewOwnership,
bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus,
restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch,
missionTableRows,
setMissionPanelField,
subscribePanel,
publishPanel,
presentMission,
renderMissionPanel,
renderMissionRoster,
escapeMissionText,
updateMissionTelemetry,
selectMissionAt,
clearMissionRosterPreviewState,
clearMissionRosterHover,
scheduleMissionRosterPreview,
createMissionPanel,
clearMissionPanel,
commands,
};
}
+196
View File
@@ -0,0 +1,196 @@
// The launches layer presents its roster and selected mission as plain views
// and publishes them to subscribers; Core's mission components render them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
import { createState } from './state.js';
function fixture() {
const state = createState({
services: { overlays: {}, render: {} },
});
const calls = [];
const parts = {
overlays: {
shortMissionLabel: (name) => name,
syncMissionOverlayEntries: () => calls.push(['overlays']),
},
policyHelpers: {
missionRosterEntries: (launches) =>
launches.map((launch, index) => ({ launch, index })),
missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
},
model: {
missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
},
selection: {
setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
focusMission: (launch) => calls.push(['focus-mission', launch.id]),
focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
},
replay: {
presentReplay: () => ({ available: false }),
startMissionReplay: (id) => calls.push(['replay', id]),
stopMissionReplay: () => calls.push(['cancel']),
pauseMissionReplay: () => calls.push(['pause']),
resumeMissionReplay: () => calls.push(['resume']),
setReplaySpeed: (value) => calls.push(['speed', value]),
},
};
const panel = createPanel({ state, parts });
const views = [];
panel.subscribePanel((view) => views.push(view));
return { state, panel, views, calls };
}
const launch = (id, extra = {}) => ({
id,
name: `Mission ${id}`,
provider: 'SpaceX',
status: 'Go',
launchSite: 'Unknown launch site',
launchTime: '2026-11-01T10:00:00Z',
payloads: [],
recoveryStages: [],
...extra,
});
test('the panel publishes only once the layer starts presenting', () => {
const { state, panel, views } = fixture();
state._launches = [launch('a')];
panel.renderMissionRoster();
assert.equal(views.length, 0);
panel.createMissionPanel();
assert.deepEqual(views, [{ roster: null, mission: null }]);
panel.renderMissionRoster();
const { roster } = views.at(-1);
assert.equal(roster.count, '1 / 30D');
assert.deepEqual(roster.entries, [
{
index: 0,
id: 'a',
color: 'rgb(1, 2, 3)',
label: 'MISSION A',
ariaLabel: 'Select MISSION A',
provider: 'SpaceX',
date: '2026-11-01',
},
]);
const late = [];
panel.subscribePanel((view) => late.push(view));
assert.equal(late[0], views.at(-1));
panel.clearMissionPanel();
assert.deepEqual(views.at(-1), { roster: null, mission: null });
});
test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
const { state, panel, views, calls } = fixture();
state._launches = [launch('a'), launch('b')];
panel.createMissionPanel();
panel.renderMissionRoster();
const published = views.length;
const roster = views.at(-1).roster;
panel.commands.rosterFocus(1);
assert.equal(state._hoveredRosterLaunchId, 'b');
panel.renderMissionRoster();
assert.equal(views.length, published);
assert.equal(state._hoveredRosterLaunchId, 'b');
await new Promise((resolve) => setTimeout(resolve, 160));
assert.deepEqual(calls.at(-1), ['preview', 'b']);
state._launches = [launch('b'), launch('a')];
panel.renderMissionRoster();
assert.equal(state._hoveredRosterLaunchId, null);
assert.notEqual(views.at(-1).roster, roster);
// The list restores focus on the mission, which takes the preview back.
panel.commands.rosterFocus(0);
assert.equal(state._hoveredRosterLaunchId, 'b');
panel.clearMissionPanel();
});
test('the selected mission is presented with its rows and returns to the top', () => {
const { state, panel, views } = fixture();
state._launches = [
launch('a'),
launch('b', {
payloads: Array.from({ length: 6 }, (_, index) => ({
name: `Sat ${index}`,
amount: index === 0 ? 2 : 1,
manufacturer: 'Maker',
massKg: 500,
})),
recoveryStages: [
{
name: 'B1080',
status: 'Landed',
flightNumber: 9,
reused: true,
endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
},
],
}),
];
panel.createMissionPanel();
state._selectedLaunchId = 'b';
panel.renderMissionPanel();
const first = views.at(-1).mission;
assert.equal(first.title, 'MISSION B');
assert.equal(first.site, null);
assert.equal(first.orbit, 'LEO');
assert.equal(first.index, '2 / 2');
assert.equal(first.previousDisabled, false);
assert.equal(first.nextDisabled, true);
assert.equal(first.payloads.length, 5);
assert.deepEqual(first.payloads[0], {
name: 'Sat 0 ×2',
detail: 'Maker · 500 KG',
type: 'UNSPECIFIED',
destination: 'UNAVAILABLE',
});
assert.equal(first.payloadsMore, '+1 additional payload records');
assert.deepEqual(first.stages, [
{
name: 'B1080',
detail: 'FLIGHT 9 · REUSED',
status: 'Landed',
destination: 'UNAVAILABLE',
position: '28.40, -77.10 · DRONESHIP',
},
]);
panel.renderMissionPanel();
assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
state._selectedLaunchId = null;
panel.renderMissionPanel();
assert.equal(views.at(-1).mission, null);
});
test('the panel commands act on the selected mission', () => {
const { state, panel, calls } = fixture();
state._launches = [launch('a'), launch('b'), launch('c')];
state._selectedLaunchId = 'b';
const { commands } = panel;
commands.selectPreviousMission();
commands.selectNextMission();
commands.selectMissionAt(9);
commands.focusSelectedLaunchSite();
commands.startSelectedReplay();
commands.toggleReplayPause();
state._replayPaused = true;
commands.toggleReplayPause();
commands.cancelReplay();
commands.setReplaySpeed('2');
commands.deselectMission();
assert.deepEqual(calls, [
['select', 'a', true],
['focus-mission', 'a'],
['select', 'c', true],
['focus-mission', 'c'],
['focus-site', 'b'],
['replay', 'b'],
['pause'],
['resume'],
['cancel'],
['speed', '2'],
['select', null, undefined],
]);
});
-34
View File
@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
for (const cyber of [true, false]) {
const body = { scrollTop: 180 };
const inner = { scrollTop: 90 };
const output = { closest: () => null, removeAttribute() {} };
const state = {
_missionPanel: {
querySelector: () => output,
closest(selector) {
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
return cyber ? body : inner;
},
},
_selectedLaunchId: 'mission-1',
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
_replayTracks: new Map(),
};
const panel = createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
});
panel.renderMissionPanel();
assert.equal(body.scrollTop, cyber ? 0 : 180);
assert.equal(inner.scrollTop, cyber ? 90 : 0);
}
});
+35 -65
View File
@@ -139,8 +139,10 @@ export function createReplay({ state: layerState, services, parts, source }) {
0,
Number(preCountdownDurationSec) || 0,
);
// The hold precedes the whole countdown; ending it at -hold cut T-10 to T-5.
const preCountdownActive =
preCountdownDuration > 0 && realSecondsSinceStart < -preCountdownDuration;
preCountdownDuration > 0 &&
realSecondsSinceStart < -REPLAY_COUNTDOWN_DURATION_SEC;
const countdownActive = realSecondsSinceStart < 0 && !preCountdownActive;
const countdownSeconds = countdownActive
? Math.ceil(-realSecondsSinceStart)
@@ -179,60 +181,41 @@ export function createReplay({ state: layerState, services, parts, source }) {
};
}
/** Republish the mission panel after a replay state change. */
function syncReplayButton() {
const button = layerState._missionPanel?.querySelector(
'[data-mission-replay]',
);
const transport = layerState._missionPanel?.querySelector(
'[data-mission-replay-transport]',
);
const speedControl = layerState._missionPanel?.querySelector(
'.mission-replay-speed-control',
);
if (!button) return;
parts.panel.publishPanel();
}
/**
* The replay controls as the mission panel shows them: the start button,
* the transport with its pause toggle, and the speed slider.
*/
function presentReplay() {
const active = Boolean(
layerState._replayCameraLaunchId &&
layerState._replayCameraLaunchId === layerState._selectedLaunchId,
);
const replayAvailable = Boolean(
const available = Boolean(
layerState._selectedLaunchId &&
layerState._replayTracks.has(layerState._selectedLaunchId),
);
if (speedControl) speedControl.hidden = !replayAvailable;
button.hidden = active || !replayAvailable;
button.disabled = !replayAvailable;
button.textContent = t('REPLAY ASCENT');
button.classList.remove('active');
button.setAttribute('aria-pressed', String(active));
button.title = t('Replay the estimated ascent with a following camera');
if (transport) {
transport.hidden = !active;
transport.classList.toggle(
'is-paused',
active && layerState._replayPaused,
);
const toggleButton = transport.querySelector(
'[data-mission-replay-toggle]',
);
if (toggleButton) {
toggleButton.disabled = !active;
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
toggleButton.setAttribute(
'aria-label',
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
);
toggleButton.title = layerState._replayPaused
? t('Resume replay')
: t('Pause replay');
}
}
const paused = layerState._replayPaused;
const speed = layerState._replaySpeed;
return {
available,
active,
paused: active && paused,
toggleText: paused ? '▶' : 'Ⅱ',
toggleLabel: paused ? t('Resume replay') : t('Pause replay'),
transportLabel: layerState._replayTransportLabel,
speed: String(speed),
speedText: `${speed.toFixed(speed % 1 ? 2 : 0)}×`,
speedProgress: `${((speed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100}%`,
};
}
function syncReplayCountdownButton(state) {
const transport = layerState._missionPanel?.querySelector(
'[data-mission-replay-transport]',
);
if (!transport || !layerState._replayCameraLaunchId) return;
if (!layerState._replayCameraLaunchId) return;
const phase = state.countdownActive
? t('T minus {seconds}', {
seconds: state.countdownSeconds,
@@ -244,29 +227,14 @@ export function createReplay({ state: layerState, services, parts, source }) {
: state.ascending
? t('Ascent replay')
: t('Orbit replay');
transport.setAttribute(
'aria-label',
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
);
const label = `${phase}${layerState._replayPaused ? t(', paused') : ''}`;
if (label === layerState._replayTransportLabel) return;
layerState._replayTransportLabel = label;
parts.panel.publishPanel();
}
function syncReplaySpeedControl() {
const input = layerState._missionPanel?.querySelector(
'[data-mission-replay-speed]',
);
const output = layerState._missionPanel?.querySelector(
'[data-mission-replay-speed-output]',
);
if (input) {
input.value = String(layerState._replaySpeed);
const progress =
((layerState._replaySpeed - REPLAY_SPEED_MIN) /
(REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) *
100;
input.style.setProperty('--replay-speed-progress', `${progress}%`);
}
if (output)
output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`;
parts.panel.publishPanel();
}
function setReplaySpeed(value) {
@@ -281,7 +249,8 @@ export function createReplay({ state: layerState, services, parts, source }) {
? layerState._replayPausedAtMs
: Date.now();
for (const [launchId, startedAt] of layerState._animationStarts) {
if (!Number.isFinite(startedAt)) continue;
// A countdown runs in real time; rescaling it turned T-8 into T-32 at 0.25x.
if (!Number.isFinite(startedAt) || startedAt > now) continue;
const elapsedMissionMs = (now - startedAt) * previousSpeed;
layerState._animationStarts.set(
launchId,
@@ -548,6 +517,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
replayStartAfterPause,
replayState,
syncReplayButton,
presentReplay,
syncReplayCountdownButton,
syncReplaySpeedControl,
setReplaySpeed,
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createState } from './state.js';
import { createReplay } from './replay.js';
import { createPaths } from './paths.js';
const noop = () => {};
function replayRig() {
const state = createState({
services: {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
},
});
const parts = { panel: { publishPanel: noop } };
parts.paths = createPaths({ state, parts });
return { state, replay: createReplay({ state, parts }) };
}
test('changing replay speed during the countdown keeps its real-time liftoff', (t) => {
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
const { state, replay } = replayRig();
const launch = { launchTime: '2026-07-20T10:00:00Z', timeline: [] };
state._animationStarts.set('countdown', 1_008_000); // T-8
state._animationStarts.set('flying', 990_000); // 10 s into the mission
replay.setReplaySpeed(0.25);
const startedAt = state._animationStarts.get('countdown');
assert.equal(startedAt, 1_008_000);
const countdown = replay.replayState(
launch,
startedAt,
12,
28,
5400,
state._replaySpeed,
Date.now(),
);
assert.equal(countdown.countdownSeconds, 8);
// Mission time already flown is still preserved across the change.
assert.equal(state._animationStarts.get('flying'), 960_000);
});
+121
View File
@@ -0,0 +1,121 @@
// src/layers/launches/replayOverlay.test.mjs — the launches layer presents
// its replay vehicle overlay to subscribers, and the scene chrome renders it
// inside the globe container.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useMissionReplayStore } from '../../ui/stores/missionReplay.js';
import SceneChromeView from '../../ui/views/SceneChromeView.vue';
import { createOverlays } from './overlays.js';
function overlays() {
const state = {
_replayVehicleOverlay: null,
_replayVehicleOverlayListeners: new Set(),
};
const parts = createOverlays({ state, services: {}, parts: {}, source: {} });
return Object.assign(parts, { state });
}
test('subscribers get the overlay as it is created, hidden and removed, never twice the same', () => {
const parts = overlays();
const views = [];
const unsubscribe = parts.subscribeReplayOverlay((view) => views.push(view));
assert.deepEqual(views, [null], 'a subscriber starts from the current one');
parts.createReplayVehicleOverlay();
parts.createReplayVehicleOverlay();
assert.equal(views.length, 2);
assert.equal(views[1].visible, false);
parts.hideReplayVehicleOverlay();
assert.equal(views.length, 2, 'an unchanged overlay is not sent again');
parts.destroyReplayVehicleOverlay();
assert.equal(views.at(-1), null);
parts.hideReplayVehicleOverlay();
unsubscribe();
parts.createReplayVehicleOverlay();
assert.equal(views.length, 3);
});
test('the scene chrome renders the overlay in the globe container', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(SceneChromeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = useMissionReplayStore(pinia);
const node = () => document.querySelector('.mission-replay-vehicle-overlay');
const parts = overlays();
parts.subscribeReplayOverlay((view) => store.publish(view));
await nextTick();
assert.equal(node(), null);
parts.createReplayVehicleOverlay();
await nextTick();
const element = node();
assert.equal(element.parentElement.id, 'cesiumContainer');
assert.equal(element.hidden, true);
assert.equal(element.getAttribute('aria-hidden'), 'true');
assert.equal(element.hasAttribute('data-mode'), false);
assert.equal(
element.querySelectorAll('.mission-replay-thrust ellipse').length,
6,
);
// A frame as updateReplayVehicleOverlay presents it.
parts.state._replayVehicleOverlay = {
...parts.state._replayVehicleOverlay,
visible: true,
mode: 'ascent',
thrusting: true,
paused: false,
transform: 'translate3d(10px, 20px, 0) translate(-50%, -50%)',
rotation: '12deg',
iconOpacity: '0.5',
labelOpacity: '1',
title: 'ASCENT REPLAY · TEST',
detail: 'T+00:10',
};
store.publish(parts.state._replayVehicleOverlay);
await nextTick();
assert.equal(element.hidden, false);
assert.equal(element.dataset.mode, 'ascent');
assert.ok(element.classList.contains('is-thrusting'));
assert.equal(element.classList.contains('is-paused'), false);
assert.equal(
element.style.getPropertyValue('--mission-replay-rotation'),
'12deg',
);
assert.match(element.style.transform, /translate3d\(10px, 20px, 0(px)?\)/);
assert.equal(
element.querySelector('.mission-replay-flight-symbol').style.opacity,
'0.5',
);
assert.equal(
element.querySelector('.mission-replay-orbit-dot').style.opacity,
'0.5',
);
assert.equal(
element.querySelector('.mission-replay-overlay-callout').style.opacity,
'1',
);
assert.equal(
element.querySelector('[data-replay-overlay-title]').textContent,
'ASCENT REPLAY · TEST',
);
assert.equal(
element.querySelector('[data-replay-overlay-detail]').textContent,
'T+00:10',
);
parts.hideReplayVehicleOverlay();
await nextTick();
assert.equal(element.hidden, true);
assert.equal(element.classList.contains('is-thrusting'), false);
assert.equal(
element.dataset.mode,
'ascent',
'hiding keeps the mode, as before',
);
parts.destroyReplayVehicleOverlay();
await nextTick();
assert.equal(node(), null);
});
+18 -3
View File
@@ -53,9 +53,21 @@ export function createState({ services }) {
state._launches = [];
state._missionPanel = null;
state._missionPanelReady = false;
state._missionRoster = null;
state._missionPanelView = null;
state._missionPanelSignature = null;
state._missionPanelListeners = new Set();
state._missionRosterView = null;
state._missionRosterSignature = null;
state._missionTelemetry = null;
state._missionPanelScrollRequest = 0;
state._missionRosterHoverTimer = null;
@@ -65,9 +77,10 @@ export function createState({ services }) {
state._missionHoverReticleImage = null;
// The replay vehicle overlay as its view renders it; null without one.
state._replayVehicleOverlay = null;
state._replayVehicleOverlayText = '';
state._replayVehicleOverlayListeners = new Set();
state._animationStarts = new Map();
@@ -99,6 +112,8 @@ export function createState({ services }) {
state._replayPaused = false;
state._replayTransportLabel = null;
state._replayPausedAtMs = null;
state._replaySpeed = 1;
+2 -1
View File
@@ -1,6 +1,7 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const DASH = '—';
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track % 360}°`,
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
+25
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createMilitaryFlightLayer } from './index.js';
import { createFlightState } from './state.js';
import { createTracking } from './tracking.js';
function services() {
const names = [
@@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
);
});
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
const stubs = services();
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
const tracking = createTracking({
flightState: {
feed: { _lastSource: 'Fixture aircraft' },
records: { data: new Map() },
},
services: stubs,
parts: {
queries: {
_describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }),
_toCleanText: (value) => value || '',
_formatAltitude: () => '',
},
},
layer: {},
});
assert.equal(
tracking._contextSubjectMetadata('ae1234').properties.heading,
'0°',
);
});
+2 -1
View File
@@ -25,6 +25,7 @@ import {
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -120,7 +121,7 @@ export function createTracking({
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
heading: Number.isFinite(described.track)
? `${Math.round(described.track)}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
+20
View File
@@ -1,3 +1,5 @@
import { getLocale } from '../../i18n/index.js';
export function createPresentation({
state: layerState,
services,
@@ -10,9 +12,26 @@ export function createPresentation({
return layerState._acceptedCatalogSnapshot;
}
/** Build the station categories, labelled in the current locale. */
function buildCategories() {
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(layerState._stations)
.map((category) => Object.freeze(category)),
);
layerState._categoriesLocale = getLocale();
}
/** Return an immutable snapshot consumed by the right-rail UI. */
function getRadioUIState() {
// Labels follow a language switch the next time the state is read.
if (
layerState._categories.length &&
layerState._categoriesLocale !== getLocale()
)
buildCategories();
const visible = parts.queries.visibleStations();
const selected = parts.queries.selectedStation();
return Object.freeze({
@@ -98,6 +117,7 @@ export function createPresentation({
return {
getRadioAcceptedCatalogSnapshot,
getRadioUIState,
buildCategories,
emitState,
subscribeToRadio,
subscribeToRadioPlaybackControls,
+1 -5
View File
@@ -295,11 +295,7 @@ export function createRendering({
layerState._stationById = new Map(
stations.map((station) => [station.id, station]),
);
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(stations)
.map((category) => Object.freeze(category)),
);
parts.presentation.buildCategories();
if (
layerState._filter === DEFAULT_RADIO_FILTER &&
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
+1
View File
@@ -38,6 +38,7 @@ export function createState({ services }) {
layerState._stationById = new Map();
layerState._categories = [];
layerState._categoriesLocale = null;
layerState._renderById = new Map();
+2 -1
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { twoline2satrec } from 'satellite.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import {
DENSE_REFRESH_FRAMES,
DENSE_GROUP_PATH,
@@ -123,6 +123,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
name: entry.name,
satrec,
group: 'dense',
launchYear: tleLineLaunchYear(entry.line1),
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
+3 -1
View File
@@ -1,7 +1,7 @@
import { twoline2satrec } from 'satellite.js';
import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { noradIdFromSatnum } from '../../sources/tle.js';
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
export function createIngestion({
@@ -129,6 +129,8 @@ export function createIngestion({
name: entry.name,
satrec,
group: entry.group,
// satellite.js does not keep the designator the year match reads.
launchYear: tleLineLaunchYear(entry.line1),
});
coreStale ||= entry.stale;
+42 -1
View File
@@ -1,7 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseTleText } from '../../sources/tle.js';
import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js';
import { createIngestion } from './ingestion.js';
import { createOrbits } from './orbits.js';
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
const ISS_LINE1 =
@@ -78,3 +79,43 @@ test('every Alpha-5 satellite keeps its own catalog entry through dedupe', async
assert.deepEqual([...state._points.keys()], [100000, 110001, 339999]);
assert.equal(state._count, 3);
});
test('a mission payload does not match a loaded satellite from another launch year', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
// SXM-11, international designator 2026-148A.
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26148A 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
const track = (launchTime) =>
getSatelliteOrbitTrack('Sirius SXM-11', { launchTime });
assert.equal(track('2026-06-29T02:25:00Z')?.noradId, 69728);
assert.equal(track('2030-06-29T02:25:00Z'), null);
});
test('a satellite with a blank designator keeps matching by name', async (t) => {
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
const { state, update } = ingestionFor(
[
'SXM-11',
'1 69728U 26208.65166678 .00000015 00000+0 00000+0 0 9990',
'2 69728 0.0784 264.9826 0001797 240.4588 273.9506 1.00271527 440',
].join('\n'),
);
await update();
const { getSatelliteOrbitTrack } = createOrbits({ state });
assert.equal(tleLineLaunchYear('1 69728U 26208.65166678'), null);
assert.equal(
getSatelliteOrbitTrack('Sirius SXM-11', {
launchTime: '2030-06-29T02:25:00Z',
})?.noradId,
69728,
);
});
+6 -16
View File
@@ -8,7 +8,11 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { noradIdFromSatnum, parseTleText } from '../../sources/tle.js';
import {
noradIdFromSatnum,
parseTleText,
tleLineLaunchYear,
} from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -258,13 +262,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return score;
}
function internationalDesignatorYear(satrec) {
const match = String(satrec?.intldesg || '').match(/^(\d{2})/);
if (!match) return null;
const shortYear = Number(match[1]);
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function lookupTleEntries(tleText) {
if (tleText !== layerState._lookupTleText) {
layerState._lookupTleText = tleText;
@@ -273,12 +270,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
return layerState._lookupTleEntries;
}
function tleLineLaunchYear(line1) {
const shortYear = Number(String(line1 || '').slice(9, 11));
if (!Number.isFinite(shortYear)) return null;
return shortYear >= 57 ? 1900 + shortYear : 2000 + shortYear;
}
function orbitTrackFromRecord(name, satrec) {
const referenceDate = new Date();
const current = propagatePosition(satrec, referenceDate);
@@ -350,7 +341,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
: null;
let bestScore = 0;
for (const [id, sat] of layerState._catalog) {
const designatorYear = internationalDesignatorYear(sat.satrec);
const designatorYear = sat.launchYear ?? null;
if (
launchYear !== null &&
designatorYear !== null &&
@@ -379,7 +370,6 @@ export function createOrbits({ state: layerState, services, parts, source }) {
getNextSatellitePass,
resolveSatelliteForPass,
scoreSatelliteNameMatch,
internationalDesignatorYear,
lookupTleEntries,
tleLineLaunchYear,
orbitTrackFromRecord,
+14
View File
@@ -235,6 +235,20 @@ test('dense provenance belongs to its admitted records and clears on removal', a
);
});
test('dense extras keep their launch year for mission orbit matching', async (t) => {
const f = await fixture(t);
await f.update();
f.state._params.catalog = 'dense';
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
// Every fixture set carries the 1998-067A designator.
const track = (launchTime) =>
f.parts.orbits.getSatelliteOrbitTrack('OWNED ORBIT FIXTURE 25551', {
launchTime,
});
assert.equal(track('1998-11-20T06:40:00Z')?.noradId, 25551);
assert.equal(track('2026-06-29T02:25:00Z'), null);
});
test('cancelling dense replacement preserves admitted stale extras until rebuild', async (t) => {
const f = await fixture(t);
await f.update();
+2 -1
View File
@@ -365,8 +365,9 @@ export function createStreetLevelLayer({
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
// No-break spaces keep a provider name (Google Street View) on one line.
return t('zoom in to a street to open {name}', {
name: translateKnown(entry.def.name),
name: translateKnown(entry.def.name).replace(/ /g, '\u00a0'),
});
return stats.hint || '';
}
+13 -2
View File
@@ -259,7 +259,8 @@ export function createControls({ state: layerState, services, parts, source }) {
* `error` carries this instant's health, so a live-configured layer whose
* flow feed went down reads DEGRADED with the reason instead of a stale
* LIVE coverage number. `flowCoveragePct` is shown dots on matched roads /
* all shown dots (0–100 int), excluding closures; `tilesFetched` counts flow-tile requests
* all shown dots (0–100 int, kept within 1–99 while both kinds are shown),
* excluding closures; `tilesFetched` counts flow-tile requests
* issued to the proxy this session (decode-cache hits excluded).
* @returns {{count:number, lastUpdate:number|null, loading:boolean,
* mode:'live'|'sim', error:string|null, flowCoveragePct:number,
@@ -304,8 +305,18 @@ export function createControls({ state: layerState, services, parts, source }) {
Boolean(layerState._heightPending);
const { free, slow, jam, sim } = layerState._bucketCounts;
const matched = free + slow + jam;
// The label reads 0 as "no matches" and 100 as nothing simulated, so
// rounding must not reach either while both kinds of dot are shown.
const flowCoveragePct =
matched + sim > 0 ? Math.round((100 * matched) / (matched + sim)) : 0;
matched + sim > 0
? Math.min(
sim > 0 ? 99 : 100,
Math.max(
matched > 0 ? 1 : 0,
Math.round((100 * matched) / (matched + sim)),
),
)
: 0;
const feed = parts.model.trafficFeedPresentation({
liveMode: layerState._liveMode,
fetching: loading,
+20
View File
@@ -516,6 +516,26 @@ test('late closures hide and stop dots; coverage counts shown matched dots, and
assert.equal(controls.getStats().flowCoveragePct, 80);
});
test('coverage never rounds to "no matches" or full coverage while both kinds of dot are shown', async () => {
const { state, controls } = await matchingContext(async () => []);
state._bucketCounts = { free: 1, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 1);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 1% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 1 };
assert.equal(controls.getStats().flowCoveragePct, 99);
assert.equal(
controls.getStats().loadingLabel,
'LIVE · Roads: OpenStreetMap · Flow: TomTom · 99% cov · Unmatched: simulated',
);
state._bucketCounts = { free: 300, slow: 0, jam: 0, sim: 0 };
assert.equal(controls.getStats().flowCoveragePct, 100);
state._bucketCounts = { free: 0, slow: 0, jam: 0, sim: 300 };
assert.equal(controls.getStats().flowCoveragePct, 0);
});
test('refresh failure removes cached matches and reports simulated flow', async () => {
const { flow, state, model, controls } = await matchingContext(async () => {
throw new Error('HTTP 429');
+4 -1
View File
@@ -9,6 +9,7 @@ import {
transitRouteLabel,
} from '../../data/transitFeeds.js';
import { displayMotion, playbackPosition } from './movement.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -407,7 +408,9 @@ export function buildTransitSelectionCopy(
);
if (Number.isFinite(record.bearing))
reportedMotion.push(
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
t('reported hdg {heading}°', {
heading: roundHeadingDegrees(record.bearing),
}),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
// It describes the moment of the report, which the display may not have
+12
View File
@@ -43,6 +43,18 @@ test('the selection card calls a Toronto 501 a streetcar', () => {
assert.doesNotMatch(copy.details.join(' | '), /Tram/);
});
test('the selection card reads a bearing that rounds to north as hdg 0°', () => {
const hdg = (bearing) =>
buildTransitSelectionCopy(
ttc(),
{ id: '4400', routeId: '501', timestamp: 0, bearing },
'tram',
0,
).details.find((line) => line.includes('hdg'));
assert.equal(hdg(359.4), 'reported hdg 359°');
assert.equal(hdg(359.6), 'reported hdg 0°');
});
test('detection labels carry the operator vocabulary within the field width', () => {
const klass = transitDetectionClass('tram', ttc());
assert.equal(klass, 'STREETCAR TTC');
+19 -20
View File
@@ -4,6 +4,7 @@ import {
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createCards({
vesselState,
@@ -13,28 +14,23 @@ export function createCards({
options,
}) {
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
// Pinned vessels missing from recent refreshes get a stale marker
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n');
services.readouts?.presentSelectedVessel({
text: [
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n'),
});
}
function resetSelectedVesselHud() {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = t('AIS: --');
services.readouts?.presentSelectedVessel(null);
}
function trimHudValue(value, maxLength) {
@@ -60,7 +56,8 @@ export function createCards({
if (record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
if (Number.isFinite(direction))
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
@@ -91,7 +88,9 @@ export function createCards({
[
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
@@ -181,7 +180,7 @@ export function createCards({
function formatHeading(heading) {
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
heading: roundHeadingDegrees(heading) ?? '--',
});
}
+153
View File
@@ -0,0 +1,153 @@
/**
* A weather source can describe its own products (credit, units, coverage,
* legend) in the optional `display` object of a product manifest. Each field
* is checked on its own: an absent or invalid field is dropped, and the layer
* keeps its built-in text for it. Units and legend describe the same scale,
* so they are kept only together. Accepted text is source data, shown as is
* rather than translated.
*/
const TEXT_LIMITS = Object.freeze({
source: 60,
credit: 200,
units: 40,
coverage: 48,
info: 600,
});
const LEGEND_MIN = 2;
const LEGEND_MAX = 16;
const LEGEND_LABEL_MAX = 16;
// Control and line-break characters, plus invisible or format characters:
// soft hyphen, combining grapheme joiner, Arabic letter mark, Mongolian vowel
// separator, zero-width, direction-mark, bidi embedding/override/isolate,
// variation selector, byte-order and tag characters.
const CONTROL =
/[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufe00-\ufe0f\ufeff\u{e0000}-\u{e007f}]/u;
// A number label must read as a plain decimal, as the legend formats it.
const DECIMAL = /^-?\d+(?:\.\d+)?$/;
// A label the legend formats as a number: a plain decimal, maybe with "+".
const SCALE_NUMBER = /^-?(\d+(?:\.(\d+))?)\+?$/;
const COLOR = /^#[0-9a-f]{6}$/i;
const EMPTY = Object.freeze({});
const cache = new WeakMap();
function text(value, max) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed && trimmed.length <= max && !CONTROL.test(trimmed)
? trimmed
: undefined;
}
/**
* Whether a label the legend formats as a number reads back exactly as given:
* within the safe integer range and with no digits lost to rounding, so
* '9999999999999999' (shown as 10000000000000000) and '007' are refused.
* Other text is shown as given.
*/
function exactScaleLabel(label) {
const match = SCALE_NUMBER.exec(label);
if (!match) return true;
const magnitude = Number(match[1]);
return (
magnitude <= Number.MAX_SAFE_INTEGER &&
magnitude.toFixed(match[2]?.length ?? 0) === match[1]
);
}
function number(value) {
if (!Number.isFinite(value)) return undefined;
const label = String(value);
return DECIMAL.test(label) && label.length <= LEGEND_LABEL_MAX
? label
: undefined;
}
function legend(value) {
if (
!Array.isArray(value) ||
value.length < LEGEND_MIN ||
value.length > LEGEND_MAX
)
return undefined;
const stops = [];
for (const stop of value) {
if (!stop || typeof stop !== 'object') return undefined;
const label =
typeof stop.label === 'number'
? number(stop.label)
: text(stop.label, LEGEND_LABEL_MAX);
if (label === undefined || !exactScaleLabel(label)) return undefined;
if (typeof stop.color !== 'string' || !COLOR.test(stop.color))
return undefined;
stops.push(Object.freeze([label, stop.color]));
}
return Object.freeze(stops);
}
function extent(value) {
if (!value || typeof value !== 'object') return undefined;
const { west, south, east, north } = value;
if (
![west, south, east, north].every(Number.isFinite) ||
west < -180 ||
west > 180 ||
east < -180 ||
east > 180 ||
south < -90 ||
north > 90 ||
south >= north ||
// No width: one meridian, including 180 to -180 across the antimeridian.
west === east ||
west - east >= 360
)
return undefined;
// West greater than east crosses the antimeridian.
return Object.freeze({ west, south, east, north });
}
/**
* The valid display fields of a weather manifest; `{}` when it has none.
* @param {object|null|undefined} manifest A validated weather snapshot.
* @returns {{ source?: string, credit?: string, units?: string,
* coverage?: string, info?: string,
* legend?: ReadonlyArray<readonly [string, string]>,
* extent?: { west: number, south: number, east: number, north: number } }}
*/
export function weatherDisplay(manifest) {
const display = manifest?.display;
if (!display || typeof display !== 'object' || Array.isArray(display))
return EMPTY;
const known = cache.get(manifest);
if (known) return known;
const result = {};
for (const [key, max] of Object.entries(TEXT_LIMITS)) {
const value = text(display[key], max);
if (value !== undefined) result[key] = value;
}
const stops = legend(display.legend);
if (stops && result.units !== undefined) result.legend = stops;
else delete result.units;
const box = extent(display.extent);
if (box) result.extent = box;
Object.freeze(result);
cache.set(manifest, result);
return result;
}
/** Whether a longitude/latitude falls inside an extent, across the antimeridian too. */
export function insideExtent({ west, south, east, north }, lon, lat) {
if (lat < south || lat > north) return false;
return west < east ? lon >= west && lon <= east : lon >= west || lon <= east;
}
/** Plain text for a Cesium credit, which otherwise reads its text as HTML. */
export function escapeCreditText(value) {
return value.replace(
/[&<>"']/g,
(ch) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
ch
],
);
}
+939
View File
@@ -0,0 +1,939 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { GeographicTilingScheme } from 'cesium';
import { createWeatherLayer } from './index.js';
import { createWeatherRendering } from './rendering.js';
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
import { createShellCesium, createShellScene } from './shellFixture.mjs';
import { formatScaleLabel } from '../../ui/unitLabels.js';
const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z'];
const base = {
schemaVersion: 1,
times,
latest: times[1],
bounds: { west: -180, south: -60, east: 180, north: 80 },
};
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
const quiet = () => ({
matches: false,
hidden: false,
addEventListener() {},
removeEventListener() {},
});
/**
* A layer whose renderer shows every requested frame at once. `center` is the
* map centre as [lon, lat]; `manifests` maps a product to its manifest, and
* may be changed between updates.
*/
async function shownLayer(id, manifest, params, view = {}) {
let time = null;
const flights = [];
const snapshots = [];
const manifests = { [manifest.product]: manifest };
const layer = createWeatherLayer({
id,
feed: { getSnapshot: async ({ product }) => manifests[product] },
documentRef: quiet(),
eventTarget: quiet(),
matchMedia: () => quiet(),
createRendering: () => ({
rehome() {},
setAlpha() {},
setHidden() {},
updateSnapshot(snapshot) {
snapshots.push(snapshot);
},
async setFrame(_snapshot, selected) {
time = selected;
return true;
},
clear() {
time = null;
},
getDiagnostics: () => ({ time, loading: false, error: null }),
}),
});
layer.init({
camera: {
moveEnd: { addEventListener: () => () => {} },
flyTo: ({ destination }) => flights.push(destination),
pickEllipsoid: () =>
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined,
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
});
if (params) layer.setParams(params);
layer.enable();
await layer.update();
await flush();
layer.attachShellServices({ runNavigation: (navigate) => navigate() });
return { layer, flights, manifests, snapshots };
}
const PRODUCTS = [
['weather-radar', 'radar', undefined],
['weather-lightning', 'lightning', undefined],
['weather-satellite', 'clouds-regional', undefined],
['weather-satellite', 'clouds', { product: 'clouds' }],
];
const INVALID = {
source: 42,
credit: '',
units: 'x'.repeat(41),
coverage: 'line one\nline two',
info: ['not', 'text'],
legend: [
{ label: '1', color: '#ffffff' },
{ label: '2', color: 'red' },
],
extent: { west: 0, south: 10, east: 20, north: 5 },
};
const SATELLITE_TITLE =
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.';
const SATELLITE_CREDIT =
'NOAA nowCOAST · NESDIS GOES / global satellite partners';
// Today's built-in description of each product, written out rather than
// derived from the code under test.
const BUILT_IN = {
radar: {
units: 'dBZ',
coverage: 'CONUS',
legend: [
['10', '#00ecec'],
['20', '#0000f6'],
['30', '#00c800'],
['40', '#ffff00'],
['50', '#ff9000'],
['60', '#dc0000'],
['70', '#ff00ff'],
['80', '#05ede0'],
].map(([label, color]) => ({
label,
color,
blurb: `${label} dBZ radar reflectivity`,
})),
heading: 'RADAR REFLECTIVITY · dBZ',
infoCoverage: 'Contiguous US · gaps ≠ no rain',
infoTitle:
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
chip: 'View US radar',
focus: [-180, -60, 180, 80],
credit: 'NOAA nowCOAST · NWS/OAR MRMS',
},
lightning: {
units: 'strikes/km²/min ×10³',
coverage: 'Americas + Pacific',
legend: [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
].map(([label, color]) => ({
label,
color,
blurb: `${label} strikes/km²/min ×10³ (15-minute density)`,
})),
heading: 'LIGHTNING DENSITY · 15 min accumulation',
infoCoverage:
'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³',
infoTitle:
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
chip: 'View Americas & Pacific',
focus: [110, -25, 0, 80],
credit: 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360',
},
'clouds-regional': {
units: '',
coverage: 'North America',
legend: [],
heading: 'GOES INFRARED · ~5 min',
infoCoverage: 'North America · infrared imagery',
infoTitle: SATELLITE_TITLE,
chip: 'View coverage',
focus: [-180, -60, 180, 80],
credit: SATELLITE_CREDIT,
},
clouds: {
units: '',
coverage: 'Global · 60°S–60°N',
legend: [],
heading: 'GLOBAL INFRARED · hourly',
infoCoverage: '60°S–60°N · typically 2–3 h delayed',
infoTitle: SATELLITE_TITLE,
chip: 'View coverage',
focus: [-180, -60, 180, 80],
credit: SATELLITE_CREDIT,
},
};
/** Assert the built-in description of a product, field by field. */
function assertBuiltIn(layer, product) {
const expected = BUILT_IN[product];
const controls = layer.getRowControls();
assert.equal(controls.summary.units, expected.units);
assert.equal(controls.summary.unitsVerbatim, undefined);
assert.equal(controls.summary.coverage, expected.coverage);
assert.deepEqual(controls.legend, expected.legend);
assert.ok(
controls.info.startsWith(`${expected.heading}\n`),
`${product} heading: ${controls.info}`,
);
assert.ok(
controls.info.endsWith(`\n${expected.infoCoverage}`),
`${product} coverage: ${controls.info}`,
);
assert.equal(controls.infoTitle, expected.infoTitle);
assert.equal(
controls.chips.find(({ id }) => id === 'coverage').label,
expected.chip,
);
const stats = layer.getStats();
assert.equal(stats.source, 'NOAA nowCOAST');
assert.equal(stats.sourceVerbatim, undefined);
}
for (const [id, product, params] of PRODUCTS) {
test(`${product}: a manifest without display metadata keeps today's description`, async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer, flights } = await shownLayer(
id,
{ ...base, product },
params,
);
assertBuiltIn(layer, product);
layer.setParams({ focus: true });
assert.deepEqual(
flights[0],
Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus),
);
layer.destroy();
});
}
for (const [id, product, params] of PRODUCTS) {
test(`${product}: empty or wholly invalid display metadata changes nothing`, async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const plain = await shownLayer(id, { ...base, product }, params);
const empty = await shownLayer(
id,
{ ...base, product, display: {} },
params,
);
const invalid = await shownLayer(
id,
{ ...base, product, display: INVALID },
params,
);
const expected = plain.layer.getRowControls();
assert.deepEqual(empty.layer.getRowControls(), expected);
assert.deepEqual(invalid.layer.getRowControls(), expected);
assertBuiltIn(invalid.layer, product);
for (const h of [plain, invalid]) h.layer.setParams({ focus: true });
assert.deepEqual(invalid.flights, plain.flights);
for (const h of [plain, empty, invalid]) h.layer.destroy();
});
}
const EXAMPLE = {
source: 'Example network',
credit: 'Example network · flash density',
units: 'flashes/km²/min',
coverage: '52°S–52°N',
info: 'Flash density from an example network. Not a safety warning.',
legend: [
{ label: 0.5, color: '#112233' },
{ label: '2', color: '#445566' },
{ label: '8+', color: '#AABBCC' },
],
extent: { west: -180, south: -52, east: 180, north: 52 },
};
test('valid display metadata describes the product in the row, legend and stats', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer, flights } = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: EXAMPLE,
});
const controls = layer.getRowControls();
assert.equal(controls.summary.units, 'flashes/km²/min');
assert.equal(controls.summary.unitsVerbatim, true);
assert.equal(controls.summary.coverage, '52°S–52°N');
assert.deepEqual(controls.legend, [
{ label: '0.5', color: '#112233', blurb: '0.5 flashes/km²/min' },
{ label: '2', color: '#445566', blurb: '2 flashes/km²/min' },
{ label: '8+', color: '#AABBCC', blurb: '8+ flashes/km²/min' },
]);
assert.match(controls.info, /^LIGHTNING DENSITY · 15 min accumulation\n/);
assert.match(controls.info, /\n52°S–52°N\nColor: flashes\/km²\/min$/);
assert.equal(controls.infoTitle, EXAMPLE.info);
assert.equal(
controls.chips.find(({ id }) => id === 'coverage').label,
'View coverage',
);
const stats = layer.getStats();
assert.equal(stats.source, 'Example network');
assert.equal(stats.sourceVerbatim, true);
layer.setParams({ focus: true });
assert.deepEqual(
flights[0],
Cesium.Rectangle.fromDegrees(-180, -52, 180, 52),
);
layer.destroy();
});
test('units and legend apply only as a valid pair', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const legend = [
{ label: 1, color: '#000000' },
{ label: 10, color: '#ffffff' },
];
for (const [id, product] of [
['weather-lightning', 'lightning'],
['weather-radar', 'radar'],
])
for (const display of [
{ units: 'mm/h' },
{ legend },
{ units: 'mm/h', legend: [legend[0]] },
{ units: 'x'.repeat(41), legend },
]) {
const { layer } = await shownLayer(id, { ...base, product, display });
assertBuiltIn(layer, product);
layer.destroy();
}
const radar = await shownLayer('weather-radar', {
...base,
product: 'radar',
display: { coverage: 'Europe', units: 'mm/h', legend },
});
const controls = radar.layer.getRowControls();
assert.equal(controls.summary.coverage, 'Europe');
assert.equal(controls.summary.units, 'mm/h');
assert.equal(controls.summary.unitsVerbatim, true);
assert.deepEqual(controls.legend, [
{ label: '1', color: '#000000', blurb: '1 mm/h' },
{ label: '10', color: '#ffffff', blurb: '10 mm/h' },
]);
assert.match(controls.info, /^RADAR · mm\/h\n/);
assert.match(controls.info, /\nEurope$/);
radar.layer.destroy();
const clouds = await shownLayer('weather-satellite', {
...base,
product: 'clouds-regional',
display: {
units: 'K',
legend: [
{ label: 'warm', color: '#000000' },
{ label: 'cold', color: '#ffffff' },
],
},
});
assert.equal(clouds.layer.getRowControls().summary.units, 'K');
assert.deepEqual(clouds.layer.getRowControls().legend, [
{ label: 'warm', color: '#000000', blurb: 'warm K' },
{ label: 'cold', color: '#ffffff', blurb: 'cold K' },
]);
clouds.layer.destroy();
});
test('other display fields fall back on their own', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer } = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: { ...INVALID, coverage: 'Example coverage', source: 'Example' },
});
const controls = layer.getRowControls();
assert.equal(controls.summary.coverage, 'Example coverage');
assert.equal(controls.summary.units, BUILT_IN.lightning.units);
assert.deepEqual(controls.legend, BUILT_IN.lightning.legend);
assert.equal(controls.infoTitle, BUILT_IN.lightning.infoTitle);
assert.equal(layer.getStats().source, 'Example');
layer.destroy();
});
test('the map centre is checked against the source extent when one is given', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const OUTSIDE = 'Map center is outside source coverage';
const outside = (layer) => layer.getRowControls().info.includes(OUTSIDE);
const at = async (id, product, display, center) => {
const h = await shownLayer(
id,
{ ...base, product, ...(display ? { display } : {}) },
undefined,
{ center },
);
const result = outside(h.layer);
if (result)
assert.equal(
h.layer.getRowControls().summary.status,
'Map center outside coverage',
);
h.layer.destroy();
return result;
};
// Without an extent: the manifest bounds, plus lightning's built-in gap.
assert.equal(
await at('weather-lightning', 'lightning', null, [50, 10]),
true,
);
assert.equal(
await at('weather-lightning', 'lightning', null, [-100, 10]),
false,
);
assert.equal(await at('weather-radar', 'radar', null, [50, 10]), false);
assert.equal(await at('weather-radar', 'radar', null, [50, -70]), true);
// An extent replaces the built-in gap.
const global = { extent: { west: -180, south: -52, east: 180, north: 52 } };
assert.equal(
await at('weather-lightning', 'lightning', global, [50, 10]),
false,
);
assert.equal(
await at('weather-lightning', 'lightning', global, [50, 60]),
true,
);
const europe = { extent: { west: -10, south: 35, east: 30, north: 70 } };
assert.equal(await at('weather-radar', 'radar', europe, [10, 50]), false);
assert.equal(await at('weather-radar', 'radar', europe, [-50, 50]), true);
assert.equal(await at('weather-radar', 'radar', europe, [10, 30]), true);
// West greater than east crosses the antimeridian.
const pacific = { extent: { west: 150, south: -30, east: -120, north: 60 } };
for (const [center, expected] of [
[[170, 0], false],
[[-150, 0], false],
[[0, 0], true],
[[-100, 0], true],
[[170, 70], true],
])
assert.equal(
await at('weather-radar', 'radar', pacific, center),
expected,
`centre ${center}`,
);
});
test('switching to a product without display metadata restores its built-ins', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const h = await shownLayer('weather-satellite', {
...base,
product: 'clouds-regional',
display: EXAMPLE,
});
h.manifests.clouds = { ...base, product: 'clouds' };
assert.equal(h.layer.getStats().source, 'Example network');
h.layer.setParams({ product: 'clouds' });
await flush();
assertBuiltIn(h.layer, 'clouds');
h.layer.setParams({ product: 'clouds-regional' });
await flush();
assert.equal(h.layer.getStats().source, 'Example network');
assert.equal(h.layer.getRowControls().summary.coverage, '52°S–52°N');
h.layer.destroy();
});
test('a refresh that drops display metadata restores the built-ins', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const h = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: EXAMPLE,
});
assert.equal(h.layer.getStats().source, 'Example network');
const plain = { ...base, product: 'lightning' };
h.manifests.lightning = plain;
await h.layer.update();
assertBuiltIn(h.layer, 'lightning');
assert.equal(h.snapshots.at(-1), plain, 'the renderer sees the refresh');
h.layer.destroy();
});
test('display validation bounds text, legend shape, colours and extents', () => {
const read = (display) => weatherDisplay({ display });
const scale = (legend) => read({ units: 'u', legend }).legend;
assert.deepEqual(read(undefined), {});
assert.deepEqual(read(['credit']), {});
assert.deepEqual(read({ coverage: ' Europe ' }), { coverage: 'Europe' });
assert.deepEqual(read({ coverage: ' ' }), {});
assert.deepEqual(read({ credit: 'a
b' }), {});
assert.deepEqual(read({ credit: 'x'.repeat(200) }).credit.length, 200);
assert.deepEqual(read({ credit: 'x'.repeat(201) }), {});
assert.deepEqual(read({ info: 'x'.repeat(601) }), {});
// Invisible and direction-changing characters are rejected in every field.
for (const code of [
0x00ad, 0x034f, 0x061c, 0x180e, 0x200b, 0x200d, 0x200e, 0x200f, 0x202a,
0x202d, 0x202e, 0x2060, 0x2066, 0x2069, 0xfe00, 0xfe0f, 0xfeff, 0xe0000,
0xe0001, 0xe0041, 0xe007f,
]) {
const ch = String.fromCodePoint(code);
const value = `a${ch}b`;
for (const key of ['source', 'credit', 'units', 'coverage', 'info'])
assert.equal(
read({ [key]: value, legend: [{ label: 1, color: '#000000' }] })[key],
undefined,
`${key} with U+${ch.codePointAt(0).toString(16)}`,
);
assert.equal(
scale([
{ label: '0', color: '#000000' },
{ label: value, color: '#000000' },
]),
undefined,
);
}
assert.equal(read({ coverage: 'a b · c–d' }).coverage, 'a b · c–d');
const stop = (label, color = '#000000') => ({ label, color });
assert.equal(scale([stop('1')]), undefined);
assert.equal(
scale(Array.from({ length: 17 }, (_, i) => stop(String(i)))),
undefined,
);
for (const bad of [
stop(Number.NaN),
stop(Infinity),
stop(0.1 + 0.2),
stop(1e21),
stop(1e-7),
stop(''),
stop('x'.repeat(17)),
stop('1', '#fff'),
stop('1', 'rgb(0,0,0)'),
stop('1', '#00000g'),
null,
'1',
])
assert.equal(scale([stop('0'), bad]), undefined);
// A number label must read back exactly as the legend formats it.
for (const bad of [
'9999999999999999',
'9007199254740992',
'9007199254740992+',
'007',
'00.5',
9007199254740992,
])
assert.equal(scale([stop('0'), stop(bad)]), undefined, String(bad));
assert.deepEqual(
scale([
stop('9007199254740991'),
stop(-900719925474099),
stop('-0.50'),
stop('1.25+'),
stop('123456.789012345'),
]),
[
['9007199254740991', '#000000'],
['-900719925474099', '#000000'],
['-0.50', '#000000'],
['1.25+', '#000000'],
['123456.789012345', '#000000'],
],
);
for (const label of ['9007199254740991', '-0.50', '1.25+', '0.001'])
assert.equal(formatScaleLabel(label), label);
assert.equal(formatScaleLabel('9999999999999999'), '10000000000000000');
assert.deepEqual(scale([stop(0), stop(-2.5), stop('1', '#ABCDEF')]), [
['0', '#000000'],
['-2.5', '#000000'],
['1', '#ABCDEF'],
]);
// A legend needs its units and units need their legend.
assert.deepEqual(read({ units: 'u' }), {});
assert.deepEqual(read({ legend: [stop(0), stop(1)] }), {});
for (const bad of [
{ west: -10, south: 0, east: 10 },
{ west: -10, south: 0, east: 10, north: '5' },
{ west: -181, south: 0, east: 10, north: 5 },
{ west: -10, south: -91, east: 10, north: 5 },
{ west: -10, south: 5, east: 10, north: 5 },
{ west: -10, south: 6, east: 10, north: 5 },
{ west: 10, south: 0, east: 10, north: 5 },
{ west: 180, south: 0, east: -180, north: 5 },
{ west: -180, south: 0, east: -180, north: 5 },
])
assert.equal(read({ extent: bad }).extent, undefined, JSON.stringify(bad));
for (const good of [
{ west: -180, south: -90, east: 180, north: 90 },
{ west: 170, south: 0, east: -180, north: 5 },
])
assert.deepEqual(read({ extent: good }).extent, good);
const wrap = read({ extent: { west: 110, south: -25, east: 0, north: 80 } });
assert.equal(insideExtent(wrap.extent, 150, 10), true);
assert.equal(insideExtent(wrap.extent, -100, 10), true);
assert.equal(insideExtent(wrap.extent, 50, 10), false);
assert.equal(insideExtent(wrap.extent, -100, 81), false);
assert.equal(
escapeCreditText(`<b>"A" & 'B'</b>`),
'&lt;b&gt;&quot;A&quot; &amp; &#39;B&#39;&lt;/b&gt;',
);
});
function mockCanvas() {
const canvas = { width: 0, height: 0 };
canvas.getContext = () => ({
setTransform() {},
drawImage() {},
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
putImageData() {},
});
return canvas;
}
/** A globe viewer whose imagery collection records providers. */
function globeViewer() {
const providers = [];
const layers = [];
const collection = {
addImageryProvider(provider) {
providers.push(provider);
const layer = { imageryProvider: provider, alpha: 1, show: true };
layers.push(layer);
return layer;
},
contains: (layer) => layers.includes(layer),
remove(layer) {
layers.splice(layers.indexOf(layer), 1);
return true;
},
get length() {
return layers.length;
},
get: (index) => layers[index],
indexOf: (layer) => layers.indexOf(layer),
lower() {},
raise() {},
raiseToTop() {},
isDestroyed: () => false,
};
const listeners = new Set();
const viewer = {
imageryLayers: collection,
camera: {
moveEnd: { addEventListener: () => () => {} },
flyTo() {},
},
scene: {
postRender: {
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
},
primitives: createShellScene().primitives,
globe: { tilesLoaded: true },
requestRender() {},
},
};
class Provider {
constructor(options) {
this.options = options;
this.tilingScheme = new GeographicTilingScheme();
this.errorEvent = new Cesium.Event();
}
requestImage() {
return undefined;
}
}
const cesium = {
...createShellCesium(),
UrlTemplateImageryProvider: Provider,
GeographicTilingScheme,
Event: Cesium.Event,
Credit: Cesium.Credit,
Rectangle: Cesium.Rectangle,
};
const options = {
cesium,
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
arrayBuffer: async () => new ArrayBuffer(1),
}),
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
createCanvas: mockCanvas,
};
const settle = async () => {
await flush();
for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn();
await flush();
};
return { viewer, providers, options, settle };
}
/** Show a frame of `manifest` and return the rendering and its globe. */
async function shownFrame(manifest) {
const globe = globeViewer();
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
});
const shown = rendering.setFrame(manifest, times[1]);
await globe.settle();
assert.equal(await shown, true);
return { ...globe, rendering };
}
const creditOf = (providers) => providers.at(-1).credit.html;
test('the imagery credit uses the source credit as plain text, else the built-in one', async () => {
for (const product of ['radar', 'lightning', 'clouds-regional', 'clouds']) {
for (const display of [undefined, { credit: 42 }]) {
const h = await shownFrame({
...base,
product,
...(display ? { display } : {}),
});
assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product);
h.rendering.clear();
}
const h = await shownFrame({
...base,
product,
display: { credit: 'Example <network> & partners' },
});
assert.equal(
creditOf(h.providers),
'Example &lt;network&gt; &amp; partners',
);
h.rendering.clear();
}
});
test('a refreshed manifest updates the credit of the frame already shown', async () => {
for (const product of ['lightning', 'clouds']) {
const h = await shownFrame({
...base,
product,
display: { credit: 'First credit' },
});
assert.equal(creditOf(h.providers), 'First credit');
const again = (display) =>
h.rendering.setFrame(
{ ...base, product, ...(display ? { display } : {}) },
times[1],
);
assert.equal(await again({ credit: 'Second credit' }), true);
assert.equal(h.providers.length, 1, 'the frame is not restaged');
assert.equal(creditOf(h.providers), 'Second credit');
assert.equal(await again(), true);
assert.equal(creditOf(h.providers), BUILT_IN[product].credit);
h.rendering.updateSnapshot({
...base,
product,
display: { credit: 'Third credit' },
});
assert.equal(creditOf(h.providers), 'Third credit');
// Another product's manifest leaves this frame alone.
h.rendering.updateSnapshot({
...base,
product: 'radar',
display: { credit: 'Other' },
});
assert.equal(creditOf(h.providers), 'Third credit');
h.rendering.clear();
}
});
test('a refreshed manifest leaves the shown snapshot for a host switch to restage', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const staged = [];
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame(snapshot, time) {
staged.push([snapshot, time]);
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
await globe.settle();
assert.equal(await shown, true);
const refreshed = {
...first,
times: [times[1]],
bounds: { west: -10, south: 0, east: 10, north: 5 },
display: { credit: 'Second credit' },
};
rendering.updateSnapshot(refreshed);
assert.equal(creditOf(globe.providers), 'Second credit');
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
assert.deepEqual(staged, [[first, times[0]]]);
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
await globe.settle();
assert.equal(globe.providers.length, 2);
const { west, south, east, north } = first.bounds;
assert.ok(
Cesium.Rectangle.equals(
globe.providers.at(-1).options.rectangle,
Cesium.Rectangle.fromDegrees(west, south, east, north),
),
'the restaged frame keeps the bounds it was staged from',
);
assert.equal(creditOf(globe.providers), 'Second credit');
rendering.clear();
});
test('a refresh during a host-switch restage keeps the newest credit', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame() {
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
await globe.settle();
assert.equal(await shown, true);
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
rendering.updateSnapshot({ ...first, display: { credit: 'Third credit' } });
await globe.settle();
assert.equal(globe.providers.length, 2);
assert.equal(creditOf(globe.providers), 'Third credit');
rendering.clear();
});
test('a refresh while a frame loads survives a later host switch', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame() {
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
await globe.settle();
assert.equal(await shown, true);
assert.equal(creditOf(globe.providers), 'Second credit');
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
await globe.settle();
assert.equal(globe.providers.length, 2);
assert.equal(creditOf(globe.providers), 'Second credit');
rendering.clear();
});
test('a refresh before a frame is installed gives it the newest credit', async () => {
for (const product of ['lightning', 'clouds']) {
const globe = globeViewer();
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
});
const first = { ...base, product, display: { credit: 'First credit' } };
const shown = rendering.setFrame(first, times[1]);
rendering.updateSnapshot({
...first,
display: { credit: 'Second credit' },
});
await globe.settle();
assert.equal(await shown, true);
assert.equal(creditOf(globe.providers), 'Second credit', product);
rendering.clear();
}
});
test('a layer refresh with the same latest frame updates the imagery credit', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const globe = globeViewer();
let manifest = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const layer = createWeatherLayer({
id: 'weather-lightning',
feed: { getSnapshot: async () => manifest },
documentRef: quiet(),
eventTarget: quiet(),
matchMedia: () => quiet(),
createRendering: (args) =>
createWeatherRendering({ ...args, ...globe.options }),
});
layer.init(globe.viewer);
layer.enable();
const first = layer.update();
await globe.settle();
await first;
assert.equal(creditOf(globe.providers), 'First credit');
manifest = { ...manifest, display: { credit: 'Second credit' } };
await layer.update();
assert.equal(globe.providers.length, 1, 'the frame is not restaged');
assert.equal(creditOf(globe.providers), 'Second credit');
manifest = { ...base, product: 'lightning' };
await layer.update();
assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit);
layer.destroy();
});
+85 -49
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import { weatherDisplay, insideExtent } from './display.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
@@ -371,6 +372,8 @@ export function createWeatherLayer({
}
manifest = snapshot;
error = null;
// The shown frame may stay; its credit follows the refreshed manifest.
rendering?.updateSnapshot?.(snapshot);
if (clock) {
await clock.refresh();
if (
@@ -467,9 +470,11 @@ export function createWeatherLayer({
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
const b =
weatherDisplay(manifest).extent ??
(lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds);
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
@@ -526,6 +531,8 @@ export function createWeatherLayer({
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
// Source-described text is data: shown as is, never translated.
const display = weatherDisplay(manifest);
const missing =
noFrame &&
shared?.mode === 'history' &&
@@ -592,7 +599,9 @@ export function createWeatherLayer({
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
(display.extent
? !insideExtent(display.extent, lon, lat)
: lightning && lon > 0 && lon < 110));
let detail = time
? t('{state} · {time} · {lag}', {
state: followLatest ? t('Observed') : t('History'),
@@ -606,7 +615,9 @@ export function createWeatherLayer({
else if (relation === 'nearest')
detail = t('{detail} · nearest', { detail });
const infoHeading = radar
? t('RADAR REFLECTIVITY · dBZ')
? display.units !== undefined
? t('RADAR · {units}', { units: display.units })
: t('RADAR REFLECTIVITY · dBZ')
: lightning
? t('LIGHTNING DENSITY · 15 min accumulation')
: product === 'clouds'
@@ -631,13 +642,24 @@ export function createWeatherLayer({
: [t('Observation: unavailable'), loading ? t('loading') : '']
.filter(Boolean)
.join(' · ');
const infoCoverage = radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery');
const coverageLine =
display.coverage ??
(radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? t('Americas + Pacific · not individual strikes')
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery'));
const infoCoverage = lightning
? `${coverageLine}\n${
display.units === undefined
? t('Color: strikes/km²/min ×10³')
: t('Color: {units}', { units: display.units })
}`
: coverageLine;
const stops =
display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []);
const infoMotion = motion?.matches
? clock
? t('Reduced motion · history playback unavailable')
@@ -651,13 +673,15 @@ export function createWeatherLayer({
: lightning
? t('Lightning density · 15 min')
: t('Satellite clouds'),
coverage: radar
? 'CONUS'
: lightning
? t('Americas + Pacific')
: product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America'),
coverage:
display.coverage ??
(radar
? 'CONUS'
: lightning
? t('Americas + Pacific')
: product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America')),
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail,
@@ -674,7 +698,11 @@ export function createWeatherLayer({
: outside
? t('Map center outside coverage')
: null),
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
units:
display.units ??
(radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : ''),
// Source units are data: the panel shows them untranslated.
...(display.units !== undefined ? { unitsVerbatim: true } : {}),
},
chips: [
...(satellite
@@ -723,43 +751,47 @@ export function createWeatherLayer({
})),
{
id: 'coverage',
label: radar
? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
label:
display.coverage !== undefined || display.extent !== undefined
? t('View coverage')
: radar
? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
}))
: [],
legend: stops.map(([label, color]) => ({
label: String(label),
color,
blurb: display.legend
? `${formatScaleLabel(label)} ${display.units}`
: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
})),
info: hostHidden
? shownHost
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
infoTitle: lightning
? t(
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
)
: radar
infoTitle:
display.info ??
(lightning
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
)
: t(
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
),
: radar
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
)
: t(
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
)),
};
controls.summary.settings = [
...(satellite
@@ -805,6 +837,7 @@ export function createWeatherLayer({
listener = typeof value === 'function' ? value : null;
},
getStats() {
const { source } = weatherDisplay(manifest);
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? t('Observed') : t('History'),
@@ -812,7 +845,10 @@ export function createWeatherLayer({
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
...(source === undefined
? { source: 'NOAA nowCOAST' }
: // Source text is data: the panel shows it untranslated.
{ source, sourceVerbatim: true }),
observedAt: shownTime(),
};
},
+83 -13
View File
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js';
import { weatherDisplay, escapeCreditText } from './display.js';
import { msg } from '../../i18n/index.js';
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import {
@@ -116,6 +117,38 @@ function createGlobeRendering({
return urls;
}
function creditText(snapshot) {
const sourceCredit = weatherDisplay(snapshot).credit;
return sourceCredit !== undefined
? escapeCreditText(sourceCredit)
: snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
}
function setCredit(frame) {
const text = creditText(frame.creditSnapshot ?? frame.snapshot);
if (frame.credit?.html === text) return false;
frame.credit = new cesium.Credit(text, false);
return true;
}
/**
* Take the credit of a refreshed manifest of the same product. Each frame
* keeps the snapshot it was staged from, whose time and bounds still match,
* and remembers the refreshed one for its credit, including a frame that
* is still loading and takes its credit when installed.
*/
function updateSnapshot(snapshot) {
let changed = false;
for (const frame of new Set([current, incoming, ...retiring])) {
if (!frame || frame.product !== snapshot.product) continue;
frame.creditSnapshot = snapshot;
if (frame.credit && setCredit(frame)) changed = true;
}
if (changed) viewer.scene.requestRender();
}
function remove(frame) {
if (!frame) return;
frame.closed = true;
@@ -180,6 +213,7 @@ function createGlobeRendering({
return {
rehome,
cancelPrefetch,
updateSnapshot,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job;
try {
@@ -220,7 +254,11 @@ function createGlobeRendering({
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
async setFrame(
snapshot,
time,
{ signal, infrared = 'filtered', creditSnapshot } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
rehome();
@@ -234,14 +272,17 @@ function createGlobeRendering({
current.product === snapshot.product &&
current.kind === kind &&
current.infrared === infrared
)
) {
updateSnapshot(creditSnapshot ?? snapshot);
return true;
}
lastError = null;
const { west, south, east, north } = snapshot.bounds;
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
const global = snapshot.product === 'clouds';
const frame = {
snapshot,
creditSnapshot,
time,
infrared,
mosaic: global
@@ -342,14 +383,8 @@ function createGlobeRendering({
}
: undefined,
);
const credit = new cesium.Credit(
snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
false,
);
setCredit(frame);
const credit = frame.credit;
const provider = global
? createRasterTileProvider({
cesium,
@@ -373,6 +408,13 @@ function createGlobeRendering({
enablePickFeatures: false,
credit,
});
// Cesium reads a provider's credit every frame; a refreshed manifest
// can replace it without restaging the imagery.
Object.defineProperty(provider, 'credit', {
configurable: true,
enumerable: true,
get: () => frame.credit,
});
const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (frame.closed) return undefined;
@@ -574,6 +616,8 @@ export function createWeatherRendering({
let hidden = false;
let shown = null;
let restaging = null;
// The last refreshed manifest, so a frame shown after it keeps its credit.
let refreshed = null;
// A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => {
@@ -602,8 +646,13 @@ export function createWeatherRendering({
restaging = null;
if (!ok) onChange();
};
// The restaged frame takes the newest refreshed credit; a refresh while
// it loads reaches it through the globe's own updateSnapshot.
target
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
.setFrame(frame.snapshot, frame.time, {
infrared: frame.infrared,
...(target === globe ? { creditSnapshot: shown?.latest } : {}),
})
.then(settle, () => settle(false));
}
function switchHost() {
@@ -645,11 +694,31 @@ export function createWeatherRendering({
switchHost();
restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const before = refreshed;
const ok = await target.setFrame(snapshot, time, options);
if (ok)
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
if (ok) {
// Keep a refresh that arrived while this frame loaded.
const latest =
refreshed !== before && refreshed?.product === snapshot.product
? refreshed
: undefined;
shown = {
snapshot,
time,
infrared: options.infrared ?? 'filtered',
...(latest ? { latest } : {}),
};
}
return ok;
},
// The shown frame keeps its staged snapshot for a restage on another
// host; only the refreshed credit is remembered and applied.
updateSnapshot(snapshot) {
refreshed = snapshot;
if (shown?.snapshot.product === snapshot.product)
shown = { ...shown, latest: snapshot };
globe.updateSnapshot(snapshot);
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) restaging = null;
@@ -662,6 +731,7 @@ export function createWeatherRendering({
clear() {
restaging = null;
shown = null;
refreshed = null;
hidden = false;
shell?.clear();
shell = null;
+2 -5
View File
@@ -6,11 +6,8 @@ import {
windReadingResult,
windUnitChips,
} from './presentation.js';
import {
inspectWindAtCenter,
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { inspectWindAtCenter, WIND_UNITS } from './inspection.js';
import { createWindInspectionMarker } from './inspectionMarker.js';
import { t, msg, translateKnown } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
+10 -8
View File
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
const snapshot = (model) => ({
model,
@@ -302,12 +304,12 @@ test('weather summary describes the selected forecast and count remains numeric'
});
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
const nodes = [];
// The marker the scene chrome renders, as the wind layer presents it.
const marker = () => useWindInspectionStore(applicationPinia()).marker;
let listener;
let samples = 0;
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
ownerDocument: {},
getBoundingClientRect: () => ({ left: 0, top: 0 }),
};
const viewer = {
@@ -338,9 +340,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
layer.setParams({ inspect: true });
const captured = layer.getRowControls().summary.reading;
assert.equal(samples, 1);
const retained = nodes[0];
const retained = marker().owner;
layer.setParams({ units: 'mph' });
assert.equal(nodes[0], retained);
assert.ok(marker().owner === retained);
const changed = layer.getRowControls().summary.reading;
assert.equal(changed.speed, captured.speed);
assert.equal(changed.coordinates, captured.coordinates);
@@ -348,7 +350,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
assert.equal(samples, 1, 'units do not resample');
assert.equal(layer.getRowControls().summary.result.id, 'reading');
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
assert.equal(nodes.length, 1);
assert.ok(marker());
assert.equal(typeof listener, 'function');
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
layer.setParams({ overlay: 'speed' });
@@ -368,9 +370,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
assert.equal(samples, 2, 'the next explicit read samples the new center');
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
layer.setParams({ inspect: true });
assert.equal(nodes.length, 1);
assert.ok(marker());
change();
assert.equal(nodes.length, 0);
assert.equal(marker(), null);
assert.equal(layer.getRowControls().summary.reading, null);
assert.equal(layer.getRowControls().summary.result, null);
assert.equal(listener, null);
-59
View File
@@ -112,62 +112,3 @@ export function inspectWindAtCenter(
),
};
}
/** One passive marker for the captured location; it never samples on camera movement. */
export function createWindInspectionMarker({ container, viewer, cesium }) {
let removeRender = null;
let marker = null;
const clear = () => {
removeRender?.();
removeRender = null;
marker?.remove();
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument?.createElement ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
marker = container.ownerDocument.createElement('div');
marker.className = 'gev-wind-inspection-marker';
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText =
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
container.appendChild(marker);
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
marker.hidden = !visible;
if (!visible) return;
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
-52
View File
@@ -58,55 +58,3 @@ test('center inspection uses a fixed forecast reading without double-converting
'No surface reading',
);
});
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
const { createWindInspectionMarker } = await import('./inspection.js');
let listener = null;
let visible = true;
const projected = [];
const nodes = [];
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
getBoundingClientRect: () => ({ left: 5, top: 10 }),
};
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
requestRender() {},
},
};
const cesium = {
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
};
const owner = createWindInspectionMarker({ container, viewer, cesium });
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
assert.equal(nodes.length, 1);
assert.match(nodes[0].style.cssText, /pointer-events:none/);
assert.equal(nodes[0].style.left, '110px');
assert.equal(nodes[0].style.top, '220px');
viewer.camera.positionWC = { moved: true };
visible = false;
listener();
assert.equal(nodes[0].hidden, true);
assert.ok(projected.every((value) => value === point));
visible = true;
listener();
assert.equal(nodes[0].hidden, false);
owner.show({ x: 4 });
assert.equal(nodes.length, 1);
owner.clear();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
});
+80
View File
@@ -0,0 +1,80 @@
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
/**
* One passive marker for the captured location; it never samples on camera
* movement. Where the point falls is presented to the wind inspection store
* after every rendered frame, and the scene chrome renders the marker in
* `container`.
*/
export function createWindInspectionMarker({
container,
viewer,
cesium,
store = useWindInspectionStore(applicationPinia()),
}) {
const owner = {};
let removeRender = null;
let marker = null;
// Only the owner whose marker the store shows may move or remove it.
const shown = () => marker && store.marker?.owner === owner;
const present = (patch) => {
marker = { ...marker, ...patch };
store.publish(marker);
};
const clear = () => {
removeRender?.();
removeRender = null;
if (shown()) store.publish(null);
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
present({ owner, container, visible: false, left: 0, top: 0 });
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
if (!shown()) return;
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
if (!visible) {
// A hidden marker keeps its last place, as the element did.
if (marker.visible) present({ visible: false });
return;
}
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const left = point.x + canvasRect.left - containerRect.left;
const top = point.y + canvasRect.top - containerRect.top;
if (marker.visible && marker.left === left && marker.top === top)
return;
present({ visible: true, left, top });
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
+137
View File
@@ -0,0 +1,137 @@
// src/layers/wind/inspectionMarker.test.mjs — the wind inspection marker
// follows only its captured point, renders in its container, and releases
// its frame listener.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue';
import { createWindInspectionMarker } from './inspectionMarker.js';
const cesium = {
SceneMode: { SCENE3D: 3 },
Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class {
isPointVisible() {
return cesium.visible;
}
},
visible: true,
};
function fixture(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.getBoundingClientRect = () => ({ left: 5, top: 10 });
const pinia = createPinia();
const app = createApp(WindInspectionMarker).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const frames = { listener: null };
const projected = [];
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: {
clientWidth: 800,
clientHeight: 600,
getBoundingClientRect: () => ({ left: 15, top: 30 }),
},
postRender: {
addEventListener(fn) {
assert.equal(frames.listener, null);
frames.listener = fn;
return () => {
frames.listener = null;
};
},
},
cartesianToCanvasCoordinates(point) {
projected.push(point);
return { x: 100, y: 200 };
},
requestRender() {},
},
};
const store = useWindInspectionStore(pinia);
cesium.visible = true;
const owner = createWindInspectionMarker({
container,
viewer,
cesium,
store,
});
const nodes = () => [
...container.querySelectorAll('.gev-wind-inspection-marker'),
];
return { owner, viewer, store, frames, projected, nodes, container, cesium };
}
test('the marker follows only its captured point, hides beyond the limb, and releases its listener', async (t) => {
const { owner, viewer, frames, projected, nodes } = fixture(t);
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
await nextTick();
assert.equal(nodes().length, 1);
const [marker] = nodes();
assert.equal(marker.getAttribute('aria-hidden'), 'true');
assert.equal(marker.style.pointerEvents, 'none');
assert.equal(marker.style.position, 'absolute');
assert.equal(marker.style.zIndex, '150');
assert.equal(marker.style.left, '110px');
assert.equal(marker.style.top, '220px');
assert.equal(marker.hidden, false);
viewer.camera.positionWC = { moved: true };
cesium.visible = false;
frames.listener();
await nextTick();
assert.equal(marker.hidden, true);
assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place');
assert.ok(projected.every((value) => value === point));
cesium.visible = true;
frames.listener();
await nextTick();
assert.equal(marker.hidden, false);
owner.show({ x: 4 });
await nextTick();
assert.equal(nodes().length, 1);
owner.clear();
await nextTick();
assert.equal(nodes().length, 0);
assert.equal(frames.listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
await nextTick();
assert.equal(nodes().length, 0);
assert.equal(frames.listener, null);
});
test('an unchanged frame publishes nothing, and an older owner leaves a newer marker', (t) => {
const { owner, viewer, store, frames, container } = fixture(t);
owner.show({ x: 1 });
let publishes = 0;
const publish = store.publish;
store.publish = (next) => {
publishes += 1;
publish(next);
};
frames.listener();
assert.equal(publishes, 0);
frames.listener = null;
const newer = createWindInspectionMarker({
container,
viewer,
cesium,
store,
});
newer.show({ x: 2 });
const shown = store.marker;
owner.destroy();
assert.ok(store.marker === shown);
newer.destroy();
assert.equal(store.marker, null);
});