mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
merge main 07094d9 into feat/voice-outlines
This commit is contained in:
commit
c5aa0d28c3
593 files changed
+42386
-19629
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+126
-157
@@ -2,7 +2,12 @@ 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';
|
||||
|
||||
@@ -43,76 +48,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 +145,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) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[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 +250,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)} · ${
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
clearMissionPanel: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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,
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -179,60 +179,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 +225,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) {
|
||||
@@ -548,6 +514,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
replayStartAfterPause,
|
||||
replayState,
|
||||
syncReplayButton,
|
||||
presentReplay,
|
||||
syncReplayCountdownButton,
|
||||
syncReplaySpeedControl,
|
||||
setReplaySpeed,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createState({ services }) {
|
||||
layerState._stationById = new Map();
|
||||
|
||||
layerState._categories = [];
|
||||
layerState._categoriesLocale = null;
|
||||
|
||||
layerState._renderById = new Map();
|
||||
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
|
||||
@@ -405,9 +405,12 @@ export function buildTransitSelectionCopy(
|
||||
reportedMotion.push(
|
||||
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
|
||||
);
|
||||
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(
|
||||
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
|
||||
t('reported hdg {heading}°', {
|
||||
heading: Math.round(record.bearing) % 360,
|
||||
}),
|
||||
);
|
||||
// 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
|
||||
|
||||
@@ -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');
|
||||
|
||||
+12
-17
@@ -13,28 +13,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) {
|
||||
|
||||
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
|
||||
ch
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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>`),
|
||||
'<b>"A" & 'B'</b>',
|
||||
);
|
||||
});
|
||||
|
||||
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 <network> & 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
@@ -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(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user