Merge Core main into german/hal-644-ar: Arabic on the Vue UI

Main moved the UI to Vue views and Pinia stores, so the Arabic changes to
the old DOM controllers now live in their views: the rail legend range
comes from formatScaleRange (keeping units a source supplies as given),
the voice timeout options are worded by Intl unit formatting in
VoiceControl, and the scene editor's JSON fields stay left to right in
SceneDialog. Main's CCTV status line, launch panel and calibration values
keep the Arabic isolates and Western-digit formatting.

isolate() and keepCodesWithValues() read the locale through getLocale(),
which follows the page's vue-i18n instance, and the Intl formatters take
intlLocale(getLocale()). A language switch now applies in place, and
applyDocumentLanguage sets <html dir> on every switch, so the page mirrors
without a reload.

The icon rails and their tips are placed with logical properties, so they
stay on the outer side of the mirrored stacks; Cyber's new stack offsets,
padding and edge lines are logical too, and rtl.css moves Cyber's right
stack to its new rail clearance. Main removed the Context mini radio, so its
right-to-left rules and test go.

The Arabic catalog gains main's new strings and drops the ones main no
longer uses; the other catalogs keep main's new keys and the branch's
landing labels.
This commit is contained in:
Sameh Khamis committed 2026-11-01 20:15:02 -08:00
commit bc5d038afd
630 files changed
+41141 -20956

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,
);
});
+4 -3
View File
@@ -51,12 +51,13 @@ export function createAwarenessLayer({ services, source }) {
parts.queries.collectAircraftProximityWindow,
_getAwarenessNavigationStateForTest:
parts.testing._getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest:
parts.testing._getAwarenessPanelRendersForTest,
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
historySubjectSnapshot: parts.history.historySubjectSnapshot,
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
subscribePanel: parts.panel.subscribePanel,
subscribeDirectionOverlay: parts.rendering.subscribeDirectionOverlay,
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
awarenessNeedsContinuousRender:
parts.model.awarenessNeedsContinuousRender,
+2 -16
View File
@@ -229,22 +229,8 @@ export function createLifecycle({
destroy() {
this.disable();
detachRuntimeListeners();
layerState.panel?.removeEventListener(
'click',
layerState.panelClickListener,
);
layerState.panelClickListener = null;
if (layerState.panelOwned) layerState.panel?.remove();
else if (layerState.panel) layerState.panel.replaceChildren();
layerState.panel = null;
layerState.panelOwned = false;
layerState.directionRoot?.remove();
layerState.directionRoot = null;
layerState.compassRing = null;
layerState.compassLabels = [];
layerState.compassHeading = null;
layerState.directionMarkers = [];
layerState.panelMarkup = '';
parts.panel.clearPanel();
parts.rendering.removeDirectionOverlay();
layerState.subjectListener = null;
layerState.contextListener = null;
layerState.clearListener = null;
+126 -157
View File
@@ -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) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;',
})[char],
);
}
function namedAreasHtml(names) {
function presentNames(names) {
return names?.length
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
: '';
? {
summary: t('Named areas ({n})', { n: formatNumber(names.length) }),
names: [...names].map((name) => String(name ?? '')),
}
: null;
}
function renderResults() {
if (!layerState.enabled || !layerState.results) return hidePanel();
const panel = ensurePanel();
ensurePanel();
layerState.panelRenders += 1;
const { subject, cohorts } = layerState.results;
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
${namedAreasHtml(subject.memberNames)}
${navigationControlsHtml()}
${cohorts.map(rowHtml).join('')}
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
panel.hidden = false;
if (layerState.panelMarkup !== markup) {
const focusSnapshot = captureAwarenessPanelFocus(panel);
panel.innerHTML = markup;
layerState.panelMarkup = markup;
restoreAwarenessPanelFocus(panel, focusSnapshot);
}
publish({
kind: 'results',
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
subjectNames: presentNames(subject.memberNames),
navigation: presentNavigation(),
rows: cohorts.map(presentRow),
note: t(
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
),
});
}
function rotateAwarenessPages() {
@@ -277,14 +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,
+116 -81
View File
@@ -22,55 +22,75 @@ export function createRendering({
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
const { governorRequestRender } = services.render;
const CARDINALS = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
/**
* The direction overlay as its view renders it: the compass ring, its
* cardinals and heading, and up to three contact markers inside the
* viewer's container. Each part keeps its last placement while hidden.
*/
function ensureDirectionOverlay() {
if (layerState.directionRoot || !layerState.viewer)
return layerState.directionRoot;
const root = document.createElement('div');
root.id = 'military-awareness-direction-overlay';
root.hidden = true;
root.setAttribute('aria-hidden', 'true');
const compass = document.createElement('div');
compass.className = 'military-awareness-compass-ring';
const cardinals = [
['N', 0],
['E', 90],
['S', 180],
['W', 270],
];
layerState.compassLabels = cardinals.map(([label, bearing]) => {
const element = document.createElement('span');
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
element.textContent = label;
element.dataset.bearing = String(bearing);
root.appendChild(element);
return element;
if (layerState.directionView || !layerState.viewer)
return layerState.directionView;
presentDirection({
container: layerState.viewer.container,
hidden: true,
ring: null,
heading: { text: '', left: null, top: null },
labels: CARDINALS.map(([label, bearing]) => ({
label,
bearing,
left: null,
top: null,
})),
markers: Array.from({ length: 3 }, () => ({
hidden: false,
style: null,
arrow: null,
caption: { text: '', style: null },
})),
});
const heading = document.createElement('span');
heading.className = 'military-awareness-compass-heading';
root.append(compass, heading);
return layerState.directionView;
}
layerState.directionMarkers = Array.from({ length: 3 }, () => {
const marker = document.createElement('div');
marker.className = 'military-awareness-direction-marker';
const arrow = document.createElement('span');
arrow.className = 'military-awareness-direction-arrow';
arrow.textContent = '➜';
const label = document.createElement('span');
label.className = 'military-awareness-direction-label';
marker.append(arrow, label);
marker._arrow = arrow;
marker._label = label;
marker._lastAngle = 0;
root.appendChild(marker);
return marker;
});
/** Publish a change to the overlay, skipping one that changes nothing. */
function presentDirection(patch) {
const next = patch && { ...layerState.directionView, ...patch };
const signature = JSON.stringify(next, (name, value) =>
name === 'container' ? undefined : value,
);
if (next && signature === layerState.directionSignature) return;
layerState.directionSignature = next ? signature : '';
layerState.directionView = next && Object.freeze(next);
for (const listener of [...layerState.directionListeners])
listener(layerState.directionView);
}
layerState.compassRing = compass;
layerState.compassHeading = heading;
layerState.viewer.container.appendChild(root);
layerState.directionRoot = root;
return root;
/**
* Receive the direction overlay now and after every change.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribeDirectionOverlay(listener) {
layerState.directionListeners.add(listener);
listener(layerState.directionView);
return () => layerState.directionListeners.delete(listener);
}
/** Hide the overlay, keeping its last placement. */
function hideDirectionOverlay() {
if (layerState.directionView) presentDirection({ hidden: true });
}
/** Withdraw the overlay when the layer is destroyed. */
function removeDirectionOverlay() {
if (layerState.directionView) presentDirection(null);
layerState.directionAngles = [0, 0, 0];
}
function scheduleDirectionOverlayUpdate(force = false) {
@@ -104,7 +124,7 @@ export function createRendering({
!layerState.subject?.position ||
!layerState.results
) {
if (root) root.hidden = true;
if (root) presentDirection({ hidden: true });
return;
}
@@ -116,41 +136,43 @@ export function createRendering({
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
presentDirection({ hidden: true });
return;
}
root.hidden = false;
// Keep the compass comfortably inside the shader keyhole so its cardinals
// remain visible around the subject instead of hiding under edge panels.
const compassRadius = Math.max(
120,
Math.min(geometry.radius - 48, geometry.radius * 0.66),
);
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
layerState.compassRing.style.width = `${compassRadius * 2}px`;
layerState.compassRing.style.height = `${compassRadius * 2}px`;
const cameraHeading = layerState.viewer.camera.heading || 0;
const headingDeg =
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
layerState.compassRing.style.setProperty(
'--compass-rotation',
`${-headingDeg}deg`,
);
layerState.compassHeading.textContent = t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
});
layerState.compassHeading.style.left = `${geometry.centerX}px`;
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
for (const label of layerState.compassLabels) {
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
const ring = {
left: `${geometry.centerX - compassRadius}px`,
top: `${geometry.centerY - compassRadius}px`,
width: `${compassRadius * 2}px`,
height: `${compassRadius * 2}px`,
'--compass-rotation': `${-headingDeg}deg`,
};
const heading = {
text: t('HDG {value}°', {
value: String(headingDeg).padStart(3, '0'),
}),
left: `${geometry.centerX}px`,
top: `${geometry.centerY - compassRadius + 39}px`,
};
const labels = root.labels.map((label) => {
const bearing = Cesium.Math.toRadians(label.bearing);
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
const labelRadius = compassRadius - 17;
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
}
return {
...label,
left: `${geometry.centerX + Math.cos(angle) * labelRadius}px`,
top: `${geometry.centerY + Math.sin(angle) * labelRadius}px`,
};
});
const directionalCohort =
layerState.results.cohorts.find(
@@ -163,8 +185,9 @@ export function createRendering({
page,
page + AWARENESS_PAGE_SIZE,
) || [];
for (let index = 0; index < layerState.directionMarkers.length; index++) {
const marker = layerState.directionMarkers[index];
const markers = root.markers.map((marker) => ({ ...marker }));
for (let index = 0; index < markers.length; index++) {
const marker = markers[index];
const item = military[index];
if (!item?.position) {
marker.hidden = true;
@@ -183,27 +206,32 @@ export function createRendering({
const projection = celestialScreenAngle(
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
marker._lastAngle,
layerState.directionAngles[index],
);
if (projection.stable) marker._lastAngle = projection.angle;
if (projection.stable)
layerState.directionAngles[index] = projection.angle;
// Every detected contact shares the compass rim. Keeping the bearing
// markers on one radius makes them read as compass contacts rather than
// unconstrained labels floating over the map.
const markerRadius = compassRadius;
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
marker.style = {
left: `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`,
top: `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`,
opacity: String(projection.opacity),
};
const sonarFactor =
sonar?.at(
geometry.centerX + Math.cos(projection.angle) * markerRadius,
geometry.centerY + Math.sin(projection.angle) * markerRadius,
) ?? 1;
marker._arrow.style.opacity = String(sonarFactor);
marker._label.style.opacity = String(
sonar ? sonar.label(sonarFactor) : 1,
);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
marker.arrow = {
opacity: String(sonarFactor),
transform: `translate(-50%, -50%) rotate(${projection.angle}rad)`,
};
const labelStyle = {
opacity: String(sonar ? sonar.label(sonarFactor) : 1),
transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
};
const label = formatAwarenessLabel(item);
const subjectCartographic = Cesium.Cartographic.fromCartesian(
layerState.subject.position,
@@ -234,9 +262,13 @@ export function createRendering({
value: formatAwarenessDirection(item.track),
})}`
: '';
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
marker.caption = {
text: `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`,
style: labelStyle,
};
marker.hidden = false;
}
presentDirection({ hidden: false, ring, heading, labels, markers });
}
function clearVisual() {
@@ -303,6 +335,9 @@ export function createRendering({
}
return {
ensureDirectionOverlay,
subscribeDirectionOverlay,
hideDirectionOverlay,
removeDirectionOverlay,
scheduleDirectionOverlayUpdate,
cancelDirectionOverlayUpdate,
updateDirectionOverlay,
+14 -9
View File
@@ -29,20 +29,26 @@ export function createState({ services }) {
subject: null,
results: null,
visual: null,
panel: null,
panelOwned: false,
/** Whether layer views replace the panel's initial markup yet. */
panelShown: false,
/** The published panel view, its signature and its subscribers. */
panelView: null,
panelSignature: '',
panelListeners: new Set(),
/** Result renders, published or not; tests observe repaint storms. */
panelRenders: 0,
subjectListener: null,
contextListener: null,
clearListener: null,
subjectClearListener: null,
runtimeListenersAttached: false,
preRenderRemover: null,
panelClickListener: null,
directionRoot: null,
compassRing: null,
compassLabels: [],
compassHeading: null,
directionMarkers: [],
/** The published direction overlay, its signature and its subscribers. */
directionView: null,
directionSignature: '',
directionListeners: new Set(),
/** Each marker's last stable screen angle. */
directionAngles: [0, 0, 0],
navigationHistory: [],
navigationVisited: new Set(),
navigationIndex: -1,
@@ -57,7 +63,6 @@ export function createState({ services }) {
cameraMoving: false,
lastEvaluatedPosition: null,
sourceRevision: '',
panelMarkup: '',
directionFrame: null,
lastDirectionUpdateMs: 0,
activationId: 0,
+8 -1
View File
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
pendingSelectionKey: layerState.pendingSelectionKey,
};
}
return { _getAwarenessNavigationStateForTest };
/** @returns {number} How many result renders ran, published or not. */
function _getAwarenessPanelRendersForTest() {
return layerState.panelRenders;
}
return {
_getAwarenessNavigationStateForTest,
_getAwarenessPanelRendersForTest,
};
}
+10 -3
View File
@@ -99,7 +99,8 @@ export function createCards({ state: layerState, services, parts, source }) {
const candidates = [];
const screenById = new Map();
for (const record of layerState._records) {
// Cards go only on the icons the view pass shows (refreshHorizonCulling).
for (const record of layerState._viewRecords) {
const id = record.camera.id;
if (id === activeId || !record.position) continue;
let inView = false;
@@ -187,7 +188,14 @@ export function createCards({ state: layerState, services, parts, source }) {
nowMs: Date.now(),
cardLimit: budgets.cardLimit,
});
layerState._cardIds = new Set(retention.keepIds);
// Grace keeps a card briefly after it loses selection, but never on a
// camera whose icon the view pass has hidden.
const shownIds = new Set(
layerState._viewRecords.map((record) => record.camera.id),
);
layerState._cardIds = new Set(
retention.keepIds.filter((id) => shownIds.has(id)),
);
layerState._cardGraceState = retention.graceState;
// Bounded thumbnail LRU: live cards (grace included) never lose their
@@ -371,7 +379,6 @@ export function createCards({ state: layerState, services, parts, source }) {
layerState._cctvOverlayHost.clearSource(CCTV_OVERLAY_SOURCE_ID);
layerState._cctvOverlayHost.setVisible(CCTV_OVERLAY_SOURCE_ID, false);
parts.hover.clearHoverCard();
layerState._cameraMoving = false;
layerState._cardIds = new Set();
layerState._cardGraceState = new Map();
layerState._cardFrameSlots = new Map();
+8 -9
View File
@@ -205,22 +205,21 @@ export function createControls({ state: layerState, services, parts, source }) {
* @returns {{ position: Cesium.Cartesian3, id: string, type: string }[]}
*/
getDetectableObjects(options = {}) {
if (!layerState._enabled || layerState._records.length === 0) return [];
// The cameras the view pass shows, not icons it has hidden.
const records = layerState._viewRecords;
if (!layerState._enabled || records.length === 0) return [];
const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount))
: layerState._records.length;
: records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
const stride = Math.max(
1,
Math.ceil(layerState._records.length / maxCount),
);
const stride = Math.max(1, Math.ceil(records.length / maxCount));
const start = seed % stride;
const objects = [];
for (let i = start; i < layerState._records.length; i += stride) {
const camera = layerState._records[i].camera;
for (let i = start; i < records.length; i += stride) {
const camera = records[i].camera;
objects.push({
position: layerState._records[i].position,
position: records[i].position,
sourceId: camera.id,
// Short semantic code where the id is opaque (see cameraDisplayCode).
id: `CAM-${camera.code || camera.id}`,
+12
View File
@@ -581,6 +581,13 @@ export function createGeometry({ state: layerState, services, parts, source }) {
function coverageNeighborCount(targetRecord) {
if (!targetRecord) return 0;
// The summary asks on every notification; scan the catalog only when the
// camera, its pose or the catalog changed.
const { id, lat, lon, rangeM } = targetRecord.camera;
const key = `${id}|${lat}|${lon}|${rangeM}|${layerState._records.length}`;
const cached = layerState._neighborCountCache;
if (cached?.key === key && cached.records === layerState._records)
return cached.count;
let count = 0;
for (const record of layerState._records) {
if (record === targetRecord) continue;
@@ -594,6 +601,11 @@ export function createGeometry({ state: layerState, services, parts, source }) {
((targetRecord.camera.rangeM + record.camera.rangeM) / 1000) * 0.92;
if (dKm <= overlapKm) count++;
}
layerState._neighborCountCache = {
key,
records: layerState._records,
count,
};
return count;
}
+151 -17
View File
@@ -6,6 +6,8 @@ import {
GEO_TRACKING_BATCH_DELAY_MS,
GEO_LOAD_BATCH_SIZE,
GEO_LOAD_BATCH_DELAY_MS,
VIEW_GEOMETRY_ATTEMPTS,
VIEW_GEOMETRY_RETRY_MS,
} from './policy.js';
export function createGeometryQueue({
@@ -25,6 +27,8 @@ export function createGeometryQueue({
clearTimeout(layerState._geoQueueTimer);
layerState._geoQueueTimer = 0;
}
clearTimeout(layerState._viewGeometryRetryTimer);
layerState._viewGeometryRetryTimer = 0;
layerState._geoQueue = [];
layerState._geoProgressNotifier = null;
if (clearProgress) {
@@ -223,6 +227,7 @@ export function createGeometryQueue({
err?.message || err,
);
}
noteViewAttempt(record);
if (
layerState._geoLoading &&
layerState._geoLoadDone < layerState._geoLoadTotal
@@ -236,14 +241,15 @@ export function createGeometryQueue({
layerState._geoLoading = false;
if (wasInitialLoad) {
layerState._geoLoadDone = layerState._geoLoadTotal;
if (layerState._enabled) {
parts.rendering.refreshCoverageStyles();
// Geometry refinement may have replaced record.position objects — the
// one-shot drain completion re-anchors the card entries (event-driven,
// not a per-frame or timer pass).
parts.cards.refreshAmbientCards();
}
if (layerState._enabled) parts.rendering.refreshCoverageStyles();
}
// Geometry refinement may have replaced record.position objects — every
// drain's completion re-anchors the card entries (event-driven, not a
// per-frame or timer pass).
// While the camera moves, moveEnd reselects them instead.
if (layerState._enabled && !layerState._cameraMoving)
parts.cards.refreshAmbientCards();
scheduleViewGeometryRetry();
// Completion is never coalesced: subscribers must observe the final
// loading state even if the last progress tick just happened.
layerState._geoProgressNotifier?.finish();
@@ -261,16 +267,17 @@ export function createGeometryQueue({
/**
* Appends records to the geometry queue (no progress tracking) and starts
* the batch timer if idle. Used by update()'s ONE-SHOT tiles-ready completion
* pass (records left `!groundResolved` by an enable-time drain that ran while
* tiles were still streaming) so it shares the same stagger machinery as the
* initial load. Fires at most once per enable — never on a recurring timer.
* the batch timer if idle. Used by update()'s one-shot tiles-ready
* completion pass and by enqueueViewGeometry, so both share the initial
* load's stagger machinery. Never driven by a recurring timer.
* @param {Object[]} records - Camera records needing geometry refresh.
*/
function enqueueGeometryRefresh(records) {
const queued = new Set(layerState._geoQueue);
for (const record of records) {
if (!layerState._geoQueue.includes(record)) {
if (!queued.has(record)) {
queued.add(record);
layerState._geoQueue.push(record);
}
}
@@ -283,10 +290,10 @@ export function createGeometryQueue({
}
/**
* Starts the initial staggered load: orders all records active-camera-first,
* then by distance from the current viewer position (nearest first, so
* cameras likely in view refine before off-screen ones), and exposes
* loaded/total progress through uiState()/getStats() while running.
* Starts the initial staggered load over the geometry working set: active
* camera first, then by distance from the current viewer position (nearest
* first), and exposes loaded/total progress through uiState()/getStats()
* while running. Cameras outside the view refine when they come into it.
*/
function startGeometryLoadQueue() {
@@ -303,7 +310,7 @@ export function createGeometryQueue({
const refLon = carto
? Cesium.Math.toDegrees(carto.longitude)
: (active?.camera.lon ?? 0);
const pending = layerState._records
const pending = geometryWorkingSet()
.filter((record) => record !== active)
.map((record) => ({
record,
@@ -325,7 +332,134 @@ export function createGeometryQueue({
);
layerState._geoQueueTimer = setTimeout(processGeometryBatch, 0);
}
/**
* The records whose geometry the view needs: the shown icons, the active
* camera and the coverage cohort drawn around it.
* @returns {Object[]}
*/
function geometryWorkingSet() {
const records = new Set(layerState._viewRecords);
const active = parts.selection.getActiveRecord();
if (active) records.add(active);
for (const id of layerState._coverageVisibleIds) {
const record = layerState._recordById.get(id);
if (record) records.add(record);
}
return [...records];
}
/** The regime key a view attempt counts against: interim while tiles stream. */
function viewAttemptKey() {
const regime = parts.ground.currentSurfaceRegime();
const tilesReady =
regime !== 'google-3d' || parts.model.projectionTilesReady();
return { key: tilesReady ? regime : `${regime}:interim`, tilesReady };
}
/**
* Counts a processed geometry attempt against the current regime key; each
* key keeps its own count, so tile streaming going on and off does not
* reset the ready-tiles budget.
*/
function noteViewAttempt(record) {
const { key } = viewAttemptKey();
record.viewAttempts ??= {};
record.viewAttempts[key] = (record.viewAttempts[key] || 0) + 1;
record.viewAttemptAt = performance.now();
}
/**
* Whether a record may be attempted again under `key`. After a first
* unresolved attempt, the second waits out VIEW_GEOMETRY_RETRY_MS so its
* elevation cell can warm, rather than going to the next motion tick.
*/
/** Whether a record still has an attempt left under the current key. */
function withinViewBudget(record) {
const { key } = viewAttemptKey();
return (record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
}
function attemptsLeft(record, key) {
const attempts = record.viewAttempts?.[key] || 0;
if (attempts >= VIEW_GEOMETRY_ATTEMPTS) return false;
return (
attempts === 0 ||
performance.now() - record.viewAttemptAt >= VIEW_GEOMETRY_RETRY_MS
);
}
/**
* After a drain, gives working-set cameras it left ungrounded one more
* attempt once their elevation cells have had time to warm. The timer is
* set for the earliest such camera and re-arms for the rest when it fires.
*/
function scheduleViewGeometryRetry() {
if (!layerState._enabled || layerState._viewGeometryRetryTimer) return;
const { key } = viewAttemptKey();
const active = parts.selection.getActiveRecord();
let due = Infinity;
for (const record of geometryWorkingSet()) {
const attempts = record.viewAttempts?.[key] || 0;
if (
record === active ||
attempts === 0 ||
attempts >= VIEW_GEOMETRY_ATTEMPTS ||
!parts.ground.needsGeometry(record)
)
continue;
due = Math.min(due, record.viewAttemptAt + VIEW_GEOMETRY_RETRY_MS);
}
if (due === Infinity) return;
layerState._viewGeometryRetryTimer = setTimeout(
() => {
layerState._viewGeometryRetryTimer = 0;
enqueueViewGeometry();
// A drain re-arms on completion; with none running, the cameras still
// cooling down need their own timer.
if (!layerState._geoQueueTimer) scheduleViewGeometryRetry();
},
Math.max(0, due - performance.now()),
);
}
/**
* Queues working-set cameras the current surface has not grounded yet,
* after a camera move brings new ones into view. Attempts are counted when
* a record is processed, not queued, so a drain cut short by disable() is
* not lost; each record gets VIEW_GEOMETRY_ATTEMPTS per surface regime,
* spaced by VIEW_GEOMETRY_RETRY_MS, so a sample the shared floor refuses
* (too far, too high) is not retried on every move. While photoreal tiles are still streaming the attempts are
* interim, and the update() completion pass is re-armed to retry these
* records when they land. The active camera is left to selection, which
* owns its re-sampling.
*/
function enqueueViewGeometry() {
if (!layerState._enabled) return;
const { key, tilesReady } = viewAttemptKey();
const active = parts.selection.getActiveRecord();
const pending = [];
let coolingDown = false;
for (const record of geometryWorkingSet()) {
if (record === active || !parts.ground.needsGeometry(record)) continue;
if (!attemptsLeft(record, key)) {
coolingDown ||=
(record.viewAttempts?.[key] || 0) < VIEW_GEOMETRY_ATTEMPTS;
continue;
}
pending.push(record);
}
// A camera back in view during its cooldown still needs its retry timer.
if (coolingDown && !layerState._geoQueueTimer) scheduleViewGeometryRetry();
if (!pending.length) return;
if (!tilesReady) layerState._tilesReadyReenqueued = false;
enqueueGeometryRefresh(pending);
}
return {
enqueueViewGeometry,
withinViewBudget,
geometryWorkingSet,
stopGeometryLoadQueue,
createGeometryProgressNotifier,
processCctvGeometryQueueBatch,
+40 -3
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import {
SUPPORT_KEYS,
planeSupportPoints,
@@ -78,6 +79,14 @@ export function createGround({ state: layerState, services, parts, source }) {
return record?.groundResolved?.[regime] === true;
}
/**
* True until the record has geometry grounded for the regime. Cameras are
* refined only once they enter the view, so many never have geometry.
*/
function needsGeometry(record, regime = currentSurfaceRegime()) {
return !record?.frustumGeometry || !isGroundResolved(record, regime);
}
/**
* Ground altitude used for pure geometry recomputes: the given regime's
* cached resolution (`record.groundSamples[regime]` — a shared mesh/DEM floor
@@ -184,8 +193,11 @@ export function createGround({ state: layerState, services, parts, source }) {
* Task 5: applies a LATE-arriving ground-prior batch (init's bounded race
* lost — cold proxy cache / slow upstream). Pure recomputes only, no scene
* queries:
* - records without geometry yet: only the icon moves onto the prior;
* the frustum is built when the camera enters the view.
* - terrain-globe regime: the prior IS the resolution → re-run the
* resolution (updateRecordGeometry latches it) for every record.
* resolution (updateRecordGeometry latches it) for every record with
* geometry.
* - google-3d regime: records still awaiting a shared floor move from the
* catalog fallback onto the exact prior; records already holding a shared
* mesh/DEM floor keep it untouched.
@@ -198,6 +210,7 @@ export function createGround({ state: layerState, services, parts, source }) {
function applyLateGroundPriors(records, priors) {
if (!Array.isArray(records) || !Array.isArray(priors)) return;
let applied = 0;
let moved = 0;
for (let i = 0; i < records.length && i < priors.length; i++) {
const record = records[i];
const prior = priors[i];
@@ -212,6 +225,24 @@ export function createGround({ state: layerState, services, parts, source }) {
record.camera.absoluteHeightM =
prior.ellipsoid + record.camera.mountHeightM;
}
// Cameras without geometry only move their icon onto the prior, so the
// view pass projects the right point; their frustums are built when
// they enter the view, as recomputing the whole catalog here would
// stall a large one.
if (!record.frustumGeometry) {
if (record.position) {
Cesium.Cartesian3.fromDegrees(
record.camera.lon,
record.camera.lat,
record.camera.absoluteHeightM,
undefined,
record.position,
);
if (record.billboard) record.billboard.position = record.position;
moved += 1;
}
continue;
}
const regime = currentSurfaceRegime();
if (regime === 'terrain-globe') {
// Prior IS the resolution — re-latch onto the fresh value.
@@ -224,7 +255,9 @@ export function createGround({ state: layerState, services, parts, source }) {
applied += 1;
}
}
if (applied) parts.presentation.notifyListeners();
if (moved && parts.rendering.refreshHorizonCulling())
parts.geometryQueue.enqueueViewGeometry();
if (applied || moved) parts.presentation.notifyListeners();
}
/**
@@ -234,7 +267,7 @@ export function createGround({ state: layerState, services, parts, source }) {
* deck/building-top in google-3d vs bare Re:Earth DEM on globe stacks), so
* on a REGIME change (photoreal ↔ globe; bing→osm stays 'terrain-globe' and
* no-ops):
* 1. every record's geometry recomputes IMMEDIATELY from the new regime's
* 1. every record that has geometry recomputes IMMEDIATELY from the new regime's
* resolution — cached sample if that regime has one, else the Re:Earth
* prior (never blank, zero scene queries);
* 2. entering google-3d re-arms the one-shot tiles-ready completion latch so
@@ -250,6 +283,9 @@ export function createGround({ state: layerState, services, parts, source }) {
layerState._lastAppliedRegime = regime;
for (const record of layerState._records) {
// Only cameras that already have geometry are rewritten; the rest are
// grounded for the new regime when they enter the view.
if (!record.frustumGeometry) continue;
if (regime === 'terrain-globe') {
// Prior IS the resolution — latch it (zero scene queries).
record.groundSamples['terrain-globe'] = groundPriorAltFor(record);
@@ -353,6 +389,7 @@ export function createGround({ state: layerState, services, parts, source }) {
resolveFootprintGround,
groundPriorAltFor,
isGroundResolved,
needsGeometry,
groundAltFor,
rearmGroundResolution,
resolveCommittedGroundAnchor,
+3
View File
@@ -91,6 +91,9 @@ export function createCctvLayer({ services, source }) {
processGeometryBatch: parts.geometryQueue.processGeometryBatch,
hideCctvRecordVisuals: parts.rendering.hideCctvRecordVisuals,
refreshCoverageStyles: parts.rendering.refreshCoverageStyles,
refreshHorizonCulling: parts.rendering.refreshHorizonCulling,
_viewRecordIdsForTest: () =>
state._viewRecords.map((record) => record.camera.id),
clearProbeClampOnDeactivation:
parts.geometry.clearProbeClampOnDeactivation,
cctvRecordNeedsActivation: parts.model.cctvRecordNeedsActivation,
+12 -3
View File
@@ -32,13 +32,22 @@ export function createIngestion({
// projectionTilesReady() is false while a (hidden) Google tileset
// exists, so this latch effectively fires for the google-3d regime —
// terrain-globe records resolve from the prior in their drain pass.
const unresolved = layerState._records.filter(
(record) => !parts.ground.isGroundResolved(record),
);
const unresolved = parts.geometryQueue
.geometryWorkingSet()
.filter(
(record) =>
!parts.ground.isGroundResolved(record) &&
parts.geometryQueue.withinViewBudget(record),
);
if (unresolved.length)
parts.geometryQueue.enqueueGeometryRefresh(unresolved);
}
await parts.health.syncHealthState();
// New health can change which camera wins a crowded cell.
if (layerState._enabled && parts.rendering.refreshHorizonCulling()) {
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
}
parts.navigation.maybeAutoHop(now);
parts.presentation.notifyListeners();
},
+163 -3
View File
@@ -7,8 +7,14 @@ import {
CAMERA_ICON,
IDLE_CAMERA_COLOR,
CALIBRATION_RANGE_FLOOR_M,
VIEW_MOTION_REFRESH_MS,
VIEW_MOTION_MIN_MOVE_FRACTION,
VIEW_MOTION_MIN_TURN_DEG,
VIEW_MOTION_COST_FACTOR,
} from './policy.js';
const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180);
export function createLifecycle({
state: layerState,
services,
@@ -22,15 +28,94 @@ export function createLifecycle({
const { onFocusTargetAppear } = services.focus;
const { releaseContinuousRender } = services.render;
/**
* The settled-view pass: thin the icons, queue the newly visible geometry,
* reselect cards, and tell subscribers (the camera picker) when the shown
* set changed since they were last told, including by motion passes.
*/
function settleView() {
const changed = parts.rendering.refreshHorizonCulling();
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
if (changed || layerState._viewUnpublished) {
layerState._viewUnpublished = false;
parts.presentation.notifyListeners();
}
}
/** Records the camera pose the last view pass ran at. */
function noteViewPose() {
const camera = layerState._viewer?.camera;
if (!camera?.positionWC) return;
layerState._viewPose = {
position: Cesium.Cartesian3.clone(camera.positionWC),
direction:
camera.directionWC && Cesium.Cartesian3.clone(camera.directionWC),
};
}
/**
* Whether the camera moved or turned enough since the last view pass to
* change what is on screen. A camera that tracks or flies without settling
* would otherwise rerun the full pass on every tick.
*/
function viewPoseChanged() {
const camera = layerState._viewer?.camera;
const pose = layerState._viewPose;
if (!pose || !camera?.positionWC) return true;
const height = Math.max(1, camera.positionCartographic?.height ?? 0);
const moved = Cesium.Cartesian3.distance(camera.positionWC, pose.position);
if (moved > height * VIEW_MOTION_MIN_MOVE_FRACTION) return true;
if (!pose.direction || !camera.directionWC) return false;
return (
Cesium.Cartesian3.dot(camera.directionWC, pose.direction) < MIN_TURN_COS
);
}
function viewMotionTick() {
if (!layerState._enabled) return;
const now = performance.now();
const pass = layerState._viewMotionPass;
if (pass && now - pass.at < pass.ms * VIEW_MOTION_COST_FACTOR) return;
if (!viewPoseChanged()) return;
if (parts.rendering.refreshHorizonCulling())
layerState._viewUnpublished = true;
noteViewPose();
parts.geometryQueue.enqueueViewGeometry();
layerState._viewMotionPass = { at: now, ms: performance.now() - now };
}
function startViewMotionTimer() {
if (layerState._viewMotionTimer) return;
layerState._viewMotionTimer = setInterval(
viewMotionTick,
VIEW_MOTION_REFRESH_MS,
);
}
function stopViewMotionTimer() {
clearInterval(layerState._viewMotionTimer);
layerState._viewMotionTimer = 0;
}
/** Resets all module-scoped runtime state to initial values. */
function clearRuntimeState() {
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
// Idempotent — also covers a re-init without a prior destroy().
parts.cards.teardownAmbientCards();
parts.projection.clearProjectionOverlay();
layerState._records = [];
layerState._recordById = new Map();
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
layerState._viewUnpublished = false;
layerState._viewPose = null;
layerState._viewMotionPass = null;
layerState._healthById = new Map();
layerState._count = 0;
layerState._lastUpdate = null;
@@ -71,6 +156,13 @@ export function createLifecycle({
layerState._calibrationById = parts.calibration.loadCalibrationStore();
layerState._billboards = new Cesium.BillboardCollection();
// Cesium switches a property's buffer-usage hint whenever it starts or
// stops changing from one frame to the next, and every switch rewrites
// the vertex data of every billboard. The view pass toggles `show` on a
// few icons of a large catalog at a time, so on a 33K catalog each pass
// cost two full rewrites. With the hint fixed, Cesium uploads only the
// icons that changed.
layerState._billboards.computeNewBuffersUsage = () => false;
layerState._viewer.scene.primitives.add(layerState._billboards);
registerSpriteCollection('cctv', layerState._billboards);
@@ -243,28 +335,76 @@ export function createLifecycle({
// Field-test fix (2026-07-06): horizon-cull on camera settle (pairs with
// the billboards' always-on-top depth setting) + one initial pass so the
// first paint is already culled.
// A re-init against a new viewer moves the move listeners to its camera.
if (
layerState._viewListenerCamera &&
layerState._viewListenerCamera !== layerState._viewer.camera
) {
const previous = layerState._viewListenerCamera;
if (layerState._horizonCullListener)
previous.moveEnd?.removeEventListener(
layerState._horizonCullListener,
);
if (layerState._moveStartListener)
previous.moveStart?.removeEventListener(
layerState._moveStartListener,
);
layerState._horizonCullListener = null;
layerState._moveStartListener = null;
layerState._cameraMoving = false;
}
layerState._viewListenerCamera = layerState._viewer.camera;
if (!layerState._horizonCullListener) {
// Ambient cards piggyback the same settle event: moveEnd-driven
// reselection only, never per frame (refreshAmbientCards no-ops while
// the layer is disabled).
layerState._horizonCullListener = () => {
layerState._cameraMoving = false;
parts.rendering.refreshHorizonCulling();
parts.cards.refreshAmbientCards();
stopViewMotionTimer();
settleView();
};
layerState._viewer.camera.moveEnd.addEventListener(
layerState._horizonCullListener,
);
}
if (!layerState._moveStartListener) {
// Item B: hover picking pauses while the camera is in motion.
// Item B: hover picking pauses while the camera is in motion. A long
// move (cockpit flight, a drag) never settles, so the view pass also
// reruns on an interval until it does.
layerState._moveStartListener = () => {
layerState._cameraMoving = true;
// enable() starts it for a move already under way.
if (layerState._enabled) startViewMotionTimer();
};
layerState._viewer.camera.moveStart.addEventListener(
layerState._moveStartListener,
);
}
// A resize or rotation changes what is on screen without moving the
// camera.
const canvas = layerState._viewer.scene?.canvas;
if (layerState._viewResizeCanvas !== canvas) {
// A re-init against a new viewer observes its canvas instead.
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
}
if (
!layerState._viewResizeObserver &&
typeof ResizeObserver === 'function' &&
canvas
) {
let frame = 0;
layerState._viewResizeObserver = new ResizeObserver(() => {
if (frame || !layerState._enabled) return;
frame = requestAnimationFrame(() => {
frame = 0;
if (layerState._enabled) settleView();
});
});
layerState._viewResizeObserver.observe(canvas);
layerState._viewResizeCanvas = canvas;
}
parts.rendering.refreshHorizonCulling();
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
@@ -325,6 +465,12 @@ export function createLifecycle({
);
await parts.health.syncHealthState(true);
// Health ranks crowded cells: thin again now that it has arrived (a
// no-op unless enable() ran while health was loading).
if (parts.rendering.refreshHorizonCulling()) {
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
}
parts.rendering.refreshCoverageStyles();
parts.presentation.notifyListeners();
restoreSpriteOrder(layerState._viewer);
@@ -370,6 +516,9 @@ export function createLifecycle({
parts.projection.ensureProjectionRuntime(activeRecord);
parts.frames.refreshProjectionImage(activeRecord, true);
}
parts.rendering.refreshHorizonCulling();
// Re-enabled mid-flight: moveStart has already fired.
if (layerState._cameraMoving) startViewMotionTimer();
parts.geometryQueue.startGeometryLoadQueue();
parts.rendering.refreshCoverageStyles();
parts.projection.startProjectionLoop();
@@ -402,10 +551,14 @@ export function createLifecycle({
layerState._removeFocusAppearListener = null;
parts.projection.stopProjectionLoop();
parts.geometryQueue.stopGeometryLoadQueue();
stopViewMotionTimer();
// Ambient cards tear down COMPLETELY on disable (owner design point 6):
// source entries, pacer timer, in-flight handlers, and caches.
parts.cards.teardownAmbientCards();
parts.rendering.hideCctvVisuals();
// Nothing is in view while the layer is off.
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
parts.presentation.notifyListeners();
},
@@ -443,6 +596,13 @@ export function createLifecycle({
);
layerState._moveStartListener = null;
}
// The move listeners are gone; nothing will report the end of a move.
layerState._cameraMoving = false;
layerState._viewListenerCamera = null;
stopViewMotionTimer();
layerState._viewResizeObserver?.disconnect();
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
if (layerState._gizmo) {
layerState._gizmo.destroy();
layerState._gizmo = null;
+39
View File
@@ -60,6 +60,45 @@ export const GEO_PROGRESS_NOTIFY_INTERVAL_MS = 300;
export const GEO_PROGRESS_NOTIFY_BATCH_LIMIT = 10;
// View thinning: camera icons show only on or near the screen, and each
// camera competes for a world-anchored grid cell sized to about VIEW_CELL_PX
// at its distance from the viewer (in power-of-two metre steps, so a pan
// keeps the same winners until a camera's distance crosses a step). Cells
// smaller than VIEW_MIN_CELL_M do not thin:
// close up every camera shows, co-located ones included.
export const VIEW_CELL_PX = 40;
export const VIEW_MIN_CELL_M = 60;
// Icons this far past the screen edge (fraction of its size) still show, so
// a pan does not reveal empty ground before the next view pass.
export const VIEW_ICON_MARGIN = 0.5;
// While the camera moves, the view pass reruns at most this often.
export const VIEW_MOTION_REFRESH_MS = 250;
// A motion tick is skipped unless the camera moved this fraction of its
// ellipsoid height, or turned this many degrees, since the last pass.
export const VIEW_MOTION_MIN_MOVE_FRACTION = 0.01;
export const VIEW_MOTION_MIN_TURN_DEG = 0.5;
// Motion passes are spaced at least this many times the last pass's cost, so
// a large catalog spends at most about a tenth of a long move on them.
export const VIEW_MOTION_COST_FACTOR = 10;
// A view camera's geometry is attempted at most this many times per surface
// regime (a cold elevation cell can leave the first attempt unresolved); the
// retry runs this long after the drain that left it unresolved.
export const VIEW_GEOMETRY_ATTEMPTS = 2;
export const VIEW_GEOMETRY_RETRY_MS = 1500;
// Throttle for placeholder repaints — the projection RAF loop must not
// re-fill a 1080p canvas on every frame while a feed image is still loading.
+42 -3
View File
@@ -277,14 +277,53 @@ export function createPresentation({
},
activeCameraId: activeId,
activeCamera: active ? getPublicCameraState(active, activeId) : null,
cameras: layerState._records.map((record) =>
getPublicCameraState(record, activeId),
),
cameraIndex: cameraIndex(),
summary: buildSummaryText(),
};
// The full per-camera state is built only when a subscriber reads it: a
// catalog of tens of thousands would otherwise be rebuilt on every
// progress tick for listeners that only want the active camera. It reads
// the records live at that moment, and spreading or serializing the
// payload builds it, so callers that want a count use `count`.
let cameras = null;
Object.defineProperty(payload, 'cameras', {
enumerable: true,
get() {
cameras ??= layerState._records.map((record) =>
getPublicCameraState(record, activeId),
);
return cameras;
},
});
return payload;
}
/**
* `{ id, city, name }` for the active camera, then every camera the view
* pass shows: what the camera picker lists. A whole catalog would put tens
* of thousands of options in the DOM and slow every layout. The same array
* is returned until the view or the active camera changes, so a consumer
* can compare it by identity.
* @returns {{id: string, city: string, name: string}[]}
*/
function cameraIndex() {
const view = layerState._viewRecords;
const active = parts.selection.getActiveRecord();
const cached = layerState._cameraIndexCache;
if (cached?.view === view && cached.active === active) return cached.list;
const records = active
? [active, ...view.filter((record) => record !== active)]
: view;
const list = records.map(({ camera }) => ({
id: camera.id,
city: camera.city,
name: camera.name,
}));
layerState._cameraIndexCache = { view, active, list };
return list;
}
/** Dispatches the current UI state to all registered subscriber callbacks. */
function notifyListeners() {
+189 -8
View File
@@ -1,5 +1,4 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
@@ -11,8 +10,21 @@ import {
ACTIVE_COVERAGE_EDGE,
IDLE_COVERAGE_EDGE_MUTED,
ACTIVE_COVERAGE_EDGE_DEPTHFAIL,
VIEW_CELL_PX,
VIEW_ICON_MARGIN,
VIEW_MIN_CELL_M,
} from './policy.js';
/** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */
function idHash(id) {
let hash = 0x811c9dc5;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function createRendering({
state: layerState,
services,
@@ -138,24 +150,186 @@ export function createRendering({
* (Google-3D regime) nothing writes far-side depth, and the billboards are now
* always-on-top (`disableDepthTestDistance: INFINITY` — the far-zoom submerge
* fix), so without this pass London's cluster would shine through the planet
* from a US viewpoint. Pure math over ≤ catalog-size points; runs on
* camera.moveEnd + init only (event-driven — no steady-state work).
* from a US viewpoint.
*
* The same pass thins the view so a large catalog stays readable and cheap:
* icons well off screen hide, and each camera competes for a world-anchored
* cell sized to VIEW_CELL_PX at its distance (healthiest first, then a stable
* id order). The active camera shows whenever it is above the horizon
* and near the screen, even in a crowded cell. The shown records become
* `_viewRecords`, the working set for geometry, cards and the camera picker;
* the array keeps its identity while the shown set is unchanged.
*
* Runs on camera settle, throttled during motion, on resize, enable and
* activation changes — never per frame.
* @returns {boolean} Whether the shown set changed.
*/
function refreshHorizonCulling() {
if (
!layerState._enabled ||
!layerState._viewer ||
layerState._viewer.isDestroyed() ||
!layerState._records.length
)
return;
const occluder = horizonOccluder(layerState._viewer.camera);
return false;
const { camera, scene } = layerState._viewer;
const width = scene.canvas.clientWidth || scene.canvas.width || 0;
const height = scene.canvas.clientHeight || scene.canvas.height || 0;
const minX = -width * VIEW_ICON_MARGIN;
const maxX = width * (1 + VIEW_ICON_MARGIN);
const minY = -height * VIEW_ICON_MARGIN;
const maxY = height * (1 + VIEW_ICON_MARGIN);
// Ground metres one pixel covers per metre of distance from the viewer.
const fovy = Number.isFinite(camera.frustum?.fovy)
? camera.frustum.fovy
: Math.PI / 3;
const metresPerPixelPerMetre =
(2 * Math.tan(fovy / 2)) / Math.max(1, height);
const viewer = camera.positionWC;
// The horizon test of Cesium's EllipsoidalOccluder, inlined: this loop
// visits every camera in the catalog.
const inverseRadii = Cesium.Ellipsoid.WGS84.oneOverRadii;
const cx = viewer.x * inverseRadii.x;
const cy = viewer.y * inverseRadii.y;
const cz = viewer.z * inverseRadii.z;
const limbSquared = cx * cx + cy * cy + cz * cz - 1;
const activeId = layerState._activeCameraId;
const project = screenProjector(scene, camera, width, height);
const pass = (layerState._viewPassId = (layerState._viewPassId || 0) + 1);
const shown = [];
const cells = new Map();
for (const record of layerState._records) {
const position = record.billboard?.position;
if (!position) continue;
const vx = position.x * inverseRadii.x - cx;
const vy = position.y * inverseRadii.y - cy;
const vz = position.z * inverseRadii.z - cz;
const vtDotVc = -(vx * cx + vy * cy + vz * cz);
if (
limbSquared < 0
? vtDotVc > 0
: vtDotVc > limbSquared &&
(vtDotVc * vtDotVc) / (vx * vx + vy * vy + vz * vz) > limbSquared
)
continue;
const screen = project(position);
if (
!screen ||
!(screen.x >= minX && screen.x <= maxX) ||
!(screen.y >= minY && screen.y <= maxY)
)
continue;
const dx = position.x - viewer.x;
const dy = position.y - viewer.y;
const dz = position.z - viewer.z;
const cellM =
VIEW_CELL_PX *
metresPerPixelPerMetre *
Math.sqrt(dx * dx + dy * dy + dz * dz);
if (record.camera.id === activeId || !(cellM >= VIEW_MIN_CELL_M)) {
record.viewPass = pass;
shown.push(record);
continue;
}
const key = worldCellKey(record, cellM);
const rank = viewRank(record);
const holder = cells.get(key);
if (!holder || rank < holder.rank) cells.set(key, { record, rank });
}
for (const { record } of cells.values()) {
record.viewPass = pass;
shown.push(record);
}
for (const record of layerState._records) {
const bb = record.billboard;
if (!bb) continue;
const visible = occluder.isPointVisible(bb.position);
const visible = record.viewPass === pass;
if (bb.show !== visible) bb.show = visible;
}
const previous = layerState._viewRecords;
const changed =
previous.length !== shown.length ||
previous.some((record) => record.viewPass !== pass);
if (changed) layerState._viewRecords = shown;
return changed;
}
/**
* Canvas coordinates for world positions. In 3D the pass multiplies by one
* view-projection matrix per call instead of Cesium's per-point
* cartesianToCanvasCoordinates, which dominated the pass on a large
* catalog; other scene modes keep Cesium's projection.
* @returns {(position: Cesium.Cartesian3) => ({x: number, y: number}|null)}
*/
function screenProjector(scene, camera, width, height) {
const out = layerState._scratchViewScreen;
const projection = camera.frustum?.projectionMatrix;
if (
scene.mode !== Cesium.SceneMode.SCENE3D ||
!projection ||
!camera.viewMatrix
)
return (position) => scene.cartesianToCanvasCoordinates(position, out);
const m = Cesium.Matrix4.multiply(
projection,
camera.viewMatrix,
layerState._scratchViewProjection,
);
return (p) => {
const w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
const z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
// Behind the eye or before the near plane: Cesium does not draw it.
if (!(w > 0) || z < 0) return null;
out.x =
((m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12]) / w + 1) * 0.5 * width;
out.y =
(1 - (m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13]) / w) * 0.5 * height;
return out;
};
}
/**
* A grid cell fixed to the ground: `cellM` rounded up to a power of two
* metres, so the cell (and its winner) only changes when the camera's
* distance crosses a doubling: a level pan keeps it, a tilted pan or a zoom
* can move cameras near the edge of a step to the next one.
*/
function worldCellKey(record, cellM) {
const level = Math.ceil(Math.log2(cellM));
const { lat, lon } = record.camera;
// A camera's key changes only with the level (or an edited position), so
// it is kept per level.
if (
record.viewCellLevel === level &&
record.viewCellLat === lat &&
record.viewCellLon === lon
)
return record.viewCellKey;
const latStep = 2 ** level / 111_320;
const row = Math.floor(lat / latStep);
const rowLat = Cesium.Math.toRadians((row + 0.5) * latStep);
const lonStep = latStep / Math.max(0.05, Math.cos(rowLat));
record.viewCellLevel = level;
record.viewCellLat = lat;
record.viewCellLon = lon;
record.viewCellKey = `${level}:${row}:${Math.floor(lon / lonStep)}`;
return record.viewCellKey;
}
/** Lower ranks win a crowded cell: healthy feeds first, then a stable id order. */
function viewRank(record) {
const status = layerState._healthById.get(record.camera.id)?.status;
const tier =
status === 'ok'
? 0
: status === 'degraded'
? 1
: status === 'offline'
? 3
: 2;
record.viewHash ??= idHash(record.camera.id);
return tier * 2 ** 32 + record.viewHash;
}
/**
@@ -204,6 +378,8 @@ export function createRendering({
const activeId = activeRecord?.camera.id || null;
const coverageVisible =
parts.geometry.buildCoverageVisibleSet(activeRecord);
// Drawn coverage needs geometry too (geometryQueue's working set).
layerState._coverageVisibleIds = coverageVisible;
const coverageOn = layerState._coverageMode !== 'off';
const viewshedOn = layerState._coverageMode === 'viewshed';
if (coverageOn) {
@@ -215,8 +391,13 @@ export function createRendering({
for (const record of layerState._records) {
const isActive = record.camera.id === activeId;
if (record.billboard) {
record.billboard.color = cameraColorFor(record);
record.billboard.scale = isActive ? 1.25 : 1.0;
// Unchanged writes still dirty the collection, which then rewrites
// every icon's vertices: across a large catalog, write only changes.
const color = cameraColorFor(record);
if (!Cesium.Color.equals(record.billboard.color, color))
record.billboard.color = color;
const scale = isActive ? 1.25 : 1.0;
if (record.billboard.scale !== scale) record.billboard.scale = scale;
// disableDepthTestDistance stays POSITIVE_INFINITY for every billboard
// (set at creation) — see the field-test far-zoom submerge fix there.
}
+7
View File
@@ -141,6 +141,11 @@ export function createSelection({
void parts.ground.resolveFootprintGround(record);
record.activationDone = true;
parts.rendering.refreshCoverageStyles();
// The active camera's icon always shows; the previously active one
// competes for its cell again. Its coverage neighbours join the working
// set and are queued for grounding.
parts.rendering.refreshHorizonCulling();
parts.geometryQueue.enqueueViewGeometry();
// The newly active camera leaves the ambient ring (its monitor plane takes
// over); the freed slot re-fills on this same pass.
parts.cards.refreshAmbientCards();
@@ -171,7 +176,9 @@ export function createSelection({
parts.ground.groundAltFor(previous),
);
});
parts.rendering.refreshHorizonCulling();
parts.rendering.refreshCoverageStyles();
parts.geometryQueue.enqueueViewGeometry();
parts.cards.refreshAmbientCards();
layerState._gizmo?.refresh();
parts.presentation.notifyListeners();
+57
View File
@@ -76,6 +76,63 @@ export function createState({ services }) {
layerState._scratchFocusScreen = new Cesium.Cartesian2();
layerState._scratchViewScreen = new Cesium.Cartesian2();
layerState._scratchViewProjection = new Cesium.Matrix4();
// Counts view passes; a record shown by the latest pass carries its number.
layerState._viewPassId = 0;
// Records whose icons the last view pass showed (see refreshHorizonCulling):
// geometry refinement, the tiles-ready completion pass and ambient cards
// work over these, not the whole catalog.
layerState._viewRecords = [];
// Coverage ids refreshCoverageStyles last drew: part of the geometry
// working set even when their icons are thinned out.
layerState._coverageVisibleIds = new Set();
// View pass triggers besides camera settle: an interval while the camera
// moves, and a canvas resize observer.
layerState._viewMotionTimer = 0;
layerState._viewResizeObserver = null;
layerState._viewResizeCanvas = null;
// The camera the moveStart/moveEnd listeners are attached to.
layerState._viewListenerCamera = null;
// A motion pass changed the shown set and the settle pass has not yet told
// subscribers; the camera pose the last view pass ran at.
layerState._viewUnpublished = false;
layerState._viewPose = null;
// { at, ms } — when the last motion pass ran and what it cost.
layerState._viewMotionPass = null;
// Retries the view geometry a drain left unresolved, once cells warm.
layerState._viewGeometryRetryTimer = 0;
// { key, count } — the active camera's coverage-overlap count, which the
// summary reads on every notification.
layerState._neighborCountCache = null;
// { view, active, list } — the camera picker's index uiState publishes,
// rebuilt only when the view pass or the active camera changes.
layerState._cameraIndexCache = null;
// Staggered geometry-load queue state (see startGeometryLoadQueue).
layerState._geoQueue = [];
+7
View File
@@ -79,6 +79,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
* @param {boolean} [options.enabled=true] Layer enabled state.
* @param {'off'|'on'|'viewshed'} [options.coverageMode='on'] Coverage mode.
* @param {boolean} [options.showProjection=false] Projection visibility.
* @param {Map<string, Object>} [options.healthById] Health rows by camera id.
* @returns {void}
*/
@@ -89,6 +90,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
enabled = true,
coverageMode = 'on',
showProjection = false,
healthById = new Map(),
} = {}) {
layerState._viewer = viewer;
layerState._records = Array.isArray(records) ? records : [];
@@ -108,6 +110,11 @@ export function createTesting({ state: layerState, services, parts, source }) {
'on',
);
layerState._showProjection = !!showProjection;
layerState._healthById = healthById;
layerState._viewRecords = [];
layerState._cameraIndexCache = null;
layerState._coverageVisibleIds = new Set();
layerState._neighborCountCache = null;
}
return {
_pushAmbientCardEntriesForTest,
+9 -2
View File
@@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({
viewAnchor = defaultGnssViewAnchor,
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs = GNSS_WINDOW_MS,
windowMs: requestedWindowMs,
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('GNSS integrity layer requires a snapshot source');
// A source may set its own evidence window; anything but a positive finite
// duration keeps the default.
const windowMs =
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
? requestedWindowMs
: GNSS_WINDOW_MS;
let _viewer = null;
let _primitives = null;
let _request = null;
@@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({
chips: [],
info:
t(
'Regional sample · 250 nm radius · cells from visited views over 30 min',
'Regional sample · 250 nm radius · cells from visited views over {minutes} min',
{ minutes: formatNumber(provenance.window.minutes) },
) +
(_lastError && _retryAt
? ` · ${translateFeedError(_lastError)} · ${
+3 -4
View File
@@ -42,7 +42,10 @@ export function createRocketLaunchesLayer({ services, source }) {
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
parts.panel.commands,
{
subscribePanel: parts.panel.subscribePanel,
subscribeReplayOverlay: parts.overlays.subscribeReplayOverlay,
satelliteParamsForSpaceMissions:
parts.policyHelpers.satelliteParamsForSpaceMissions,
satelliteParamsAfterSpaceMissions:
@@ -69,10 +72,6 @@ export function createRocketLaunchesLayer({ services, source }) {
missionHoverPreviewRange: parts.panel.missionHoverPreviewRange,
createMissionRosterPreviewOwnership:
parts.panel.createMissionRosterPreviewOwnership,
bindMissionRosterItemKeyboardPreview:
parts.panel.bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus: parts.panel.captureMissionRosterFocus,
restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch:
parts.panel.resolveMissionRosterPreviewLaunch,
formatMissionEventTime: parts.policyHelpers.formatMissionEventTime,
+1
View File
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
renderMissionRoster: noop,
renderMissionPanel: noop,
clearMissionRosterHover: noop,
clearMissionPanel: noop,
},
replay: { stopMissionReplay: noop },
selection: {
+1 -8
View File
@@ -201,7 +201,6 @@ export function createLifecycle({
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer);
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
layerState._selectedLaunchId,
@@ -262,13 +261,7 @@ export function createLifecycle({
layerState._animationStarts.clear();
layerState._satelliteTelemetry.clear();
layerState._replayTracks.clear();
layerState._missionPanel?.remove();
layerState._missionPanel = null;
if (layerState._missionRoster) {
layerState._missionRoster.onclick = null;
}
layerState._missionRosterPreviewOwnership = null;
layerState._missionRoster = null;
parts.panel.clearMissionPanel();
layerState._viewer = null;
if (layerState._dataSource)
viewer.dataSources.remove(layerState._dataSource, true);
@@ -13,20 +13,7 @@ import { createPanel } from './panel.js';
const NOW = new Date('2026-07-27T00:00:00Z');
function shownStatus(status) {
const outputs = new Map();
const state = {
_missionPanel: {
querySelector(selector) {
if (!outputs.has(selector))
outputs.set(selector, {
textContent: '',
closest: () => null,
removeAttribute() {},
});
return outputs.get(selector);
},
closest: () => null,
},
_selectedLaunchId: 'mission-1',
_launches: [
{
@@ -39,15 +26,14 @@ function shownStatus(status) {
],
_replayTracks: new Map(),
};
createPanel({
return createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
replay: { presentReplay: () => ({}) },
},
}).renderMissionPanel();
return outputs.get('[data-mission-status]').textContent;
}).presentMission().status;
}
test('a launch without a status keeps the English fallback in its record', () => {
+62 -77
View File
@@ -280,60 +280,64 @@ export function createOverlays({ state: layerState, services, parts, source }) {
return ascending ? 'ascent' : 'orbit';
}
function createReplayVehicleOverlay() {
if (layerState._replayVehicleOverlay || typeof document === 'undefined')
/**
* Present the replay vehicle overlay to its subscribers, or null to remove
* it. An unchanged presentation is not sent again.
*/
function presentReplayVehicleOverlay(patch) {
const current = layerState._replayVehicleOverlay;
const next = patch && { ...current, ...patch };
if (
next === current ||
(next &&
current &&
Object.keys(next).every((k) => next[k] === current[k]))
)
return;
const host = document.getElementById('cesiumContainer') || document.body;
layerState._replayVehicleOverlay = document.createElement('div');
layerState._replayVehicleOverlay.className =
'mission-replay-vehicle-overlay';
layerState._replayVehicleOverlay.hidden = true;
layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true');
layerState._replayVehicleOverlay.innerHTML = `
<div class="mission-replay-flight-symbol">
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
</svg>
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
</svg>
</div>
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
<div class="mission-replay-overlay-callout">
<strong data-replay-overlay-title></strong>
<span data-replay-overlay-detail></span>
</div>`;
host.appendChild(layerState._replayVehicleOverlay);
layerState._replayVehicleOverlay._sonarIcons = [
...layerState._replayVehicleOverlay.querySelectorAll(
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
),
];
layerState._replayVehicleOverlay._sonarLabel =
layerState._replayVehicleOverlay.querySelector(
'.mission-replay-overlay-callout',
);
layerState._replayVehicleOverlay = next;
for (const listener of [...layerState._replayVehicleOverlayListeners])
listener(next);
}
/**
* Receive every presentation of the replay vehicle overlay, starting with
* the current one.
* @param {(view: ?object) => void} listener
* @returns {() => void} Unsubscribe.
*/
function subscribeReplayOverlay(listener) {
layerState._replayVehicleOverlayListeners.add(listener);
listener(layerState._replayVehicleOverlay);
return () => layerState._replayVehicleOverlayListeners.delete(listener);
}
function createReplayVehicleOverlay() {
if (layerState._replayVehicleOverlay) return;
presentReplayVehicleOverlay({
visible: false,
mode: null,
thrusting: false,
paused: false,
transform: null,
rotation: null,
iconOpacity: null,
labelOpacity: null,
title: '',
detail: '',
});
}
function hideReplayVehicleOverlay() {
if (!layerState._replayVehicleOverlay) return;
layerState._replayVehicleOverlay.hidden = true;
layerState._replayVehicleOverlay.classList.remove(
'is-thrusting',
'is-paused',
);
presentReplayVehicleOverlay({
visible: false,
thrusting: false,
paused: false,
});
}
function destroyReplayVehicleOverlay() {
layerState._replayVehicleOverlay?.remove();
layerState._replayVehicleOverlay = null;
layerState._replayVehicleOverlayText = '';
presentReplayVehicleOverlay(null);
}
function clearMissionOverlaySources() {
@@ -523,23 +527,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
: null;
const factor =
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
icon.style.opacity = String(factor);
if (layerState._replayVehicleOverlay._sonarLabel)
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
sonar ? sonar.label(factor) : 1,
);
layerState._replayVehicleOverlay.hidden = false;
layerState._replayVehicleOverlay.dataset.mode = mode;
layerState._replayVehicleOverlay.classList.toggle(
'is-thrusting',
replayActive && state.ascending && !state.countdownActive,
);
layerState._replayVehicleOverlay.classList.toggle(
'is-paused',
replayActive && layerState._replayPaused,
);
layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`;
const frame = {
visible: true,
mode,
thrusting: replayActive && state.ascending && !state.countdownActive,
paused: replayActive && layerState._replayPaused,
transform: `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`,
iconOpacity: String(factor),
labelOpacity: String(sonar ? sonar.label(factor) : 1),
};
let vehicleRotation = 0;
if (replayActive && !state.countdownActive) {
const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1));
@@ -581,10 +577,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
}
}
}
layerState._replayVehicleOverlay.style.setProperty(
'--mission-replay-rotation',
`${Cesium.Math.toDegrees(vehicleRotation)}deg`,
);
frame.rotation = `${Cesium.Math.toDegrees(vehicleRotation)}deg`;
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
const siteName = compactLaunchSiteName(launch.launchSite);
@@ -620,16 +613,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
}
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
const nextText = `${title}\n${detail}`;
if (nextText !== layerState._replayVehicleOverlayText) {
layerState._replayVehicleOverlayText = nextText;
layerState._replayVehicleOverlay.querySelector(
'[data-replay-overlay-title]',
).textContent = title;
layerState._replayVehicleOverlay.querySelector(
'[data-replay-overlay-detail]',
).textContent = detail;
}
presentReplayVehicleOverlay({ ...frame, title, detail });
return mode;
}
return {
@@ -643,6 +627,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
missionHoverReticleImage,
replayOverlayMode,
createReplayVehicleOverlay,
subscribeReplayOverlay,
hideReplayVehicleOverlay,
destroyReplayVehicleOverlay,
clearMissionOverlaySources,
+268 -360
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { formatNumber, isolate, 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,
@@ -97,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) {
@@ -167,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)
? `${formatNumber(payload.massKg)} 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
? `${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 stageDetail = [
Number.isFinite(stage.flightNumber)
? t('FLIGHT {count}', {
count: stage.flightNumber,
: null,
),
time: presentField(launch.launchTime),
orbit: presentField(pathPresentation.orbit),
ascent: pathPresentation.ascent ?? '',
distance: telemetry?.distance ?? null,
speed: telemetry?.speed ?? null,
speedTitle: telemetry?.speedTitle ?? null,
payloads: launch.payloads
.slice(0, 5)
.map((payload) => presentPayload(payload, launch)),
payloadsMore:
launch.payloads.length > 5
? t('+{count} additional payload records', {
count: launch.payloads.length - 5,
})
: null,
stage.reused ? t('REUSED') : null,
stage.recoveryType,
]
.filter(Boolean)
.join(' · ');
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
});
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
const stageSection = layerState._missionPanel.querySelector(
'[data-mission-stages-section]',
);
if (stageSection) stageSection.hidden = stageRows.length === 0;
updateMissionTelemetry(true);
layerState._missionPanel.querySelector('[data-mission-index]').textContent =
`${index + 1} / ${layerState._launches.length}`;
layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
index <= 0;
layerState._missionPanel.querySelector('[data-mission-next]').disabled =
index < 0 || index >= layerState._launches.length - 1;
parts.replay.syncReplayButton();
const panelScroller = layerState._missionPanel.closest(
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
);
if (panelScroller) panelScroller.scrollTop = 0;
stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
index: `${index + 1} / ${layerState._launches.length}`,
previousDisabled: index <= 0,
nextDisabled: index < 0 || index >= layerState._launches.length - 1,
replay: parts.replay.presentReplay(),
};
}
function renderMissionRoster() {
if (!layerState._missionRoster) return;
const list = layerState._missionRoster.querySelector(
'[data-mission-roster-list]',
);
const count = layerState._missionRoster.querySelector(
'[data-mission-roster-count]',
);
if (count)
count.textContent = t('{count} / 30D', {
count: layerState._launches.length,
});
if (!list) return;
const focusSnapshot = captureMissionRosterFocus(list);
layerState._missionRosterPreviewOwnership?.reset();
const entries = parts.policyHelpers.missionRosterEntries(
layerState._launches,
);
if (!entries.length) {
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
restoreMissionRosterFocus(
list,
focusSnapshot,
layerState._missionRoster.querySelector(
'[data-mission-roster-focus-continuation]',
),
);
return;
}
list.innerHTML = entries
/** Present the newly selected mission and return its panel to the top. */
function renderMissionPanel() {
if (!layerState._missionPanelReady) return;
layerState._missionTelemetry = null;
updateMissionTelemetry(true, false);
layerState._missionPanelScrollRequest++;
publishPanel();
}
/** The roster entries as the list shows them. */
function presentRoster() {
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
const color = parts.model.missionMarkerColor(launch).toCssColorString();
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
const label = parts.overlays
.shortMissionLabel(launch.name, 27)
.toUpperCase();
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
})
.join('');
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
const index = Number(button.dataset.missionRosterIndex);
bindMissionRosterItemKeyboardPreview(
button,
index,
layerState._missionRosterPreviewOwnership,
layerState._missionRoster,
);
});
restoreMissionRosterFocus(
list,
focusSnapshot,
layerState._missionRoster.querySelector(
'[data-mission-roster-focus-continuation]',
),
);
return {
index,
id: String(launch.id ?? ''),
color: parts.model.missionMarkerColor(launch).toCssColorString(),
label,
ariaLabel: t('Select {mission}', { mission: label }),
provider: launch.provider || t('UNSPECIFIED OPERATOR'),
date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
};
});
return {
count: t('{count} / 30D', { count: layerState._launches.length }),
entries,
empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
};
}
function escapeMissionText(value) {
return String(value ?? '').replace(
/[&<>"']/g,
(character) =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character],
);
/**
* Present the roster. A changed roster drops the hover and keyboard
* preview; the list restores keyboard focus by mission identity, which
* resumes the preview.
*/
function renderMissionRoster() {
if (!layerState._missionPanelReady) return;
const roster = presentRoster();
const signature = JSON.stringify(roster);
if (signature !== layerState._missionRosterSignature) {
layerState._missionRosterSignature = signature;
layerState._missionRosterView = roster;
layerState._missionRosterPreviewOwnership?.reset();
}
publishPanel();
}
function updateMissionTelemetry(force = false) {
/**
* Read the selected mission's altitude and speed, at most four times a
* second unless forced, and republish when they change.
*/
function updateMissionTelemetry(force = false, republish = true) {
if (
!layerState._missionPanel ||
!layerState._missionPanelReady ||
!layerState._selectedLaunchId ||
!layerState._dataSource
)
@@ -390,22 +328,18 @@ 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: 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: formatNumber(speedMps / 1000, {
minimumFractionDigits: 2,
@@ -413,10 +347,11 @@ export function createPanel({ state: layerState, services, parts, source }) {
}),
})
: null,
Number.isFinite(speedMps)
speedTitle: Number.isFinite(speedMps)
? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h`
: '',
);
: null,
};
if (republish) publishPanel();
}
function selectMissionAt(index) {
@@ -460,121 +395,94 @@ export function createPanel({ state: layerState, services, parts, source }) {
}, 140);
}
function createMissionPanel() {
if (layerState._missionPanel || typeof document === 'undefined') return;
const host =
document.getElementById('space-mission-panel-host') ||
document.getElementById('right-context-rail');
if (!host) return;
layerState._missionRoster = document.getElementById('space-mission-roster');
if (layerState._missionRoster) {
layerState._missionRosterPreviewOwnership =
createMissionRosterPreviewOwnership({
preview: scheduleMissionRosterPreview,
clear: clearMissionRosterPreviewState,
});
layerState._missionRoster.onclick = (event) => {
const button =
event.target instanceof Element
? event.target.closest('[data-mission-roster-index]')
: null;
if (!button) return;
selectMissionAt(Number(button.dataset.missionRosterIndex));
};
}
layerState._missionPanel = document.createElement('aside');
layerState._missionPanel.id = 'space-mission-panel';
layerState._missionPanel.className = 'context-space-mission-detail';
layerState._missionPanel.setAttribute(
'aria-label',
t('Selected Space Mission'),
/** Select the mission before or after the selected one. */
function selectAdjacentMission(step) {
selectMissionAt(
layerState._launches.findIndex(
(item) => item.id === layerState._selectedLaunchId,
) + step,
);
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
layerState._missionPanel
.querySelector('.mission-action-row')
.insertAdjacentHTML(
'beforeend',
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
</div>`,
);
host.appendChild(layerState._missionPanel);
layerState._missionPanel
.querySelector('[data-mission-prev]')
.addEventListener('click', () =>
selectMissionAt(
layerState._launches.findIndex(
(item) => item.id === layerState._selectedLaunchId,
) - 1,
),
);
layerState._missionPanel
.querySelector('[data-mission-next]')
.addEventListener('click', () =>
selectMissionAt(
layerState._launches.findIndex(
(item) => item.id === layerState._selectedLaunchId,
) + 1,
),
);
layerState._missionPanel
.querySelector('[data-mission-close]')
.addEventListener('click', () =>
parts.selection.setSelectedMission(null),
);
layerState._missionPanel
.querySelector('[data-mission-show-all]')
.addEventListener('click', () =>
parts.selection.setSelectedMission(null),
);
layerState._missionPanel
.querySelector('[data-mission-focus]')
.addEventListener('click', () => {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
if (launch) parts.selection.focusLaunchSite(launch);
});
layerState._missionPanel
.querySelector('[data-mission-replay]')
.addEventListener('click', () => {
if (layerState._selectedLaunchId)
parts.replay.startMissionReplay(layerState._selectedLaunchId);
});
layerState._missionPanel
.querySelector('[data-mission-replay-toggle]')
.addEventListener('click', () => {
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
else parts.replay.pauseMissionReplay();
});
layerState._missionPanel
.querySelector('[data-mission-replay-cancel]')
.addEventListener('click', parts.replay.stopMissionReplay);
layerState._missionPanel
.querySelector('[data-mission-replay-speed]')
.addEventListener('input', (event) => {
parts.replay.setReplaySpeed(event.currentTarget.value);
});
parts.replay.syncReplaySpeedControl();
}
/** The panel's commands, called by Core's mission components. */
const commands = {
selectMissionAt,
selectPreviousMission: () => selectAdjacentMission(-1),
selectNextMission: () => selectAdjacentMission(1),
deselectMission: () => parts.selection.setSelectedMission(null),
focusSelectedLaunchSite() {
const launch = layerState._launches.find(
(item) => item.id === layerState._selectedLaunchId,
);
if (launch) parts.selection.focusLaunchSite(launch);
},
startSelectedReplay() {
if (layerState._selectedLaunchId)
parts.replay.startMissionReplay(layerState._selectedLaunchId);
},
toggleReplayPause() {
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
else parts.replay.pauseMissionReplay();
},
cancelReplay: () => parts.replay.stopMissionReplay(),
setReplaySpeed: (value) => parts.replay.setReplaySpeed(value),
rosterPointerEnter: (index) =>
layerState._missionRosterPreviewOwnership?.pointerEnter(index),
rosterPointerLeave: () =>
layerState._missionRosterPreviewOwnership?.pointerLeave(),
rosterFocus: (index) =>
layerState._missionRosterPreviewOwnership?.focus(index),
rosterBlur: (nextIndex) =>
layerState._missionRosterPreviewOwnership?.blur(nextIndex),
};
/** Start presenting the roster and the selected mission. */
function createMissionPanel() {
if (layerState._missionPanelReady) return;
layerState._missionPanelReady = true;
layerState._missionRosterPreviewOwnership =
createMissionRosterPreviewOwnership({
preview: scheduleMissionRosterPreview,
clear: clearMissionRosterPreviewState,
});
// The roster and mission are worded again in a new locale.
layerState._offMissionLocale = onLocaleChange(() => {
renderMissionRoster();
updateMissionTelemetry(true, false);
publishPanel();
});
publishPanel();
}
/** Stop presenting; the components return to their initial markup. */
function clearMissionPanel() {
layerState._missionPanelReady = false;
layerState._offMissionLocale?.();
layerState._offMissionLocale = null;
layerState._missionRosterPreviewOwnership = null;
layerState._missionRosterView = null;
layerState._missionRosterSignature = null;
layerState._missionTelemetry = null;
layerState._replayTransportLabel = null;
publish({ roster: null, mission: null });
}
return {
missionHoverPreviewRange,
createMissionRosterPreviewOwnership,
bindMissionRosterItemKeyboardPreview,
captureMissionRosterFocus,
restoreMissionRosterFocus,
resolveMissionRosterPreviewLaunch,
missionTableRows,
setMissionPanelField,
subscribePanel,
publishPanel,
presentMission,
renderMissionPanel,
renderMissionRoster,
escapeMissionText,
updateMissionTelemetry,
selectMissionAt,
clearMissionRosterPreviewState,
clearMissionRosterHover,
scheduleMissionRosterPreview,
createMissionPanel,
clearMissionPanel,
commands,
};
}
+196
View File
@@ -0,0 +1,196 @@
// The launches layer presents its roster and selected mission as plain views
// and publishes them to subscribers; Core's mission components render them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
import { createState } from './state.js';
function fixture() {
const state = createState({
services: { overlays: {}, render: {} },
});
const calls = [];
const parts = {
overlays: {
shortMissionLabel: (name) => name,
syncMissionOverlayEntries: () => calls.push(['overlays']),
},
policyHelpers: {
missionRosterEntries: (launches) =>
launches.map((launch, index) => ({ launch, index })),
missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
},
model: {
missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
},
selection: {
setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
focusMission: (launch) => calls.push(['focus-mission', launch.id]),
focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
},
replay: {
presentReplay: () => ({ available: false }),
startMissionReplay: (id) => calls.push(['replay', id]),
stopMissionReplay: () => calls.push(['cancel']),
pauseMissionReplay: () => calls.push(['pause']),
resumeMissionReplay: () => calls.push(['resume']),
setReplaySpeed: (value) => calls.push(['speed', value]),
},
};
const panel = createPanel({ state, parts });
const views = [];
panel.subscribePanel((view) => views.push(view));
return { state, panel, views, calls };
}
const launch = (id, extra = {}) => ({
id,
name: `Mission ${id}`,
provider: 'SpaceX',
status: 'Go',
launchSite: 'Unknown launch site',
launchTime: '2026-11-01T10:00:00Z',
payloads: [],
recoveryStages: [],
...extra,
});
test('the panel publishes only once the layer starts presenting', () => {
const { state, panel, views } = fixture();
state._launches = [launch('a')];
panel.renderMissionRoster();
assert.equal(views.length, 0);
panel.createMissionPanel();
assert.deepEqual(views, [{ roster: null, mission: null }]);
panel.renderMissionRoster();
const { roster } = views.at(-1);
assert.equal(roster.count, '1 / 30D');
assert.deepEqual(roster.entries, [
{
index: 0,
id: 'a',
color: 'rgb(1, 2, 3)',
label: 'MISSION A',
ariaLabel: 'Select MISSION A',
provider: 'SpaceX',
date: '2026-11-01',
},
]);
const late = [];
panel.subscribePanel((view) => late.push(view));
assert.equal(late[0], views.at(-1));
panel.clearMissionPanel();
assert.deepEqual(views.at(-1), { roster: null, mission: null });
});
test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
const { state, panel, views, calls } = fixture();
state._launches = [launch('a'), launch('b')];
panel.createMissionPanel();
panel.renderMissionRoster();
const published = views.length;
const roster = views.at(-1).roster;
panel.commands.rosterFocus(1);
assert.equal(state._hoveredRosterLaunchId, 'b');
panel.renderMissionRoster();
assert.equal(views.length, published);
assert.equal(state._hoveredRosterLaunchId, 'b');
await new Promise((resolve) => setTimeout(resolve, 160));
assert.deepEqual(calls.at(-1), ['preview', 'b']);
state._launches = [launch('b'), launch('a')];
panel.renderMissionRoster();
assert.equal(state._hoveredRosterLaunchId, null);
assert.notEqual(views.at(-1).roster, roster);
// The list restores focus on the mission, which takes the preview back.
panel.commands.rosterFocus(0);
assert.equal(state._hoveredRosterLaunchId, 'b');
panel.clearMissionPanel();
});
test('the selected mission is presented with its rows and returns to the top', () => {
const { state, panel, views } = fixture();
state._launches = [
launch('a'),
launch('b', {
payloads: Array.from({ length: 6 }, (_, index) => ({
name: `Sat ${index}`,
amount: index === 0 ? 2 : 1,
manufacturer: 'Maker',
massKg: 500,
})),
recoveryStages: [
{
name: 'B1080',
status: 'Landed',
flightNumber: 9,
reused: true,
endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
},
],
}),
];
panel.createMissionPanel();
state._selectedLaunchId = 'b';
panel.renderMissionPanel();
const first = views.at(-1).mission;
assert.equal(first.title, 'MISSION B');
assert.equal(first.site, null);
assert.equal(first.orbit, 'LEO');
assert.equal(first.index, '2 / 2');
assert.equal(first.previousDisabled, false);
assert.equal(first.nextDisabled, true);
assert.equal(first.payloads.length, 5);
assert.deepEqual(first.payloads[0], {
name: 'Sat 0 ×2',
detail: 'Maker · 500 KG',
type: 'UNSPECIFIED',
destination: 'UNAVAILABLE',
});
assert.equal(first.payloadsMore, '+1 additional payload records');
assert.deepEqual(first.stages, [
{
name: 'B1080',
detail: 'FLIGHT 9 · REUSED',
status: 'Landed',
destination: 'UNAVAILABLE',
position: '28.40, -77.10 · DRONESHIP',
},
]);
panel.renderMissionPanel();
assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
state._selectedLaunchId = null;
panel.renderMissionPanel();
assert.equal(views.at(-1).mission, null);
});
test('the panel commands act on the selected mission', () => {
const { state, panel, calls } = fixture();
state._launches = [launch('a'), launch('b'), launch('c')];
state._selectedLaunchId = 'b';
const { commands } = panel;
commands.selectPreviousMission();
commands.selectNextMission();
commands.selectMissionAt(9);
commands.focusSelectedLaunchSite();
commands.startSelectedReplay();
commands.toggleReplayPause();
state._replayPaused = true;
commands.toggleReplayPause();
commands.cancelReplay();
commands.setReplaySpeed('2');
commands.deselectMission();
assert.deepEqual(calls, [
['select', 'a', true],
['focus-mission', 'a'],
['select', 'c', true],
['focus-mission', 'c'],
['focus-site', 'b'],
['replay', 'b'],
['pause'],
['resume'],
['cancel'],
['speed', '2'],
['select', null, undefined],
]);
});
-34
View File
@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
for (const cyber of [true, false]) {
const body = { scrollTop: 180 };
const inner = { scrollTop: 90 };
const output = { closest: () => null, removeAttribute() {} };
const state = {
_missionPanel: {
querySelector: () => output,
closest(selector) {
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
return cyber ? body : inner;
},
},
_selectedLaunchId: 'mission-1',
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
_replayTracks: new Map(),
};
const panel = createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
});
panel.renderMissionPanel();
assert.equal(body.scrollTop, cyber ? 0 : 180);
assert.equal(inner.scrollTop, cyber ? 90 : 0);
}
});
+30 -63
View File
@@ -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,
+121
View File
@@ -0,0 +1,121 @@
// src/layers/launches/replayOverlay.test.mjs — the launches layer presents
// its replay vehicle overlay to subscribers, and the scene chrome renders it
// inside the globe container.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useMissionReplayStore } from '../../ui/stores/missionReplay.js';
import SceneChromeView from '../../ui/views/SceneChromeView.vue';
import { createOverlays } from './overlays.js';
function overlays() {
const state = {
_replayVehicleOverlay: null,
_replayVehicleOverlayListeners: new Set(),
};
const parts = createOverlays({ state, services: {}, parts: {}, source: {} });
return Object.assign(parts, { state });
}
test('subscribers get the overlay as it is created, hidden and removed, never twice the same', () => {
const parts = overlays();
const views = [];
const unsubscribe = parts.subscribeReplayOverlay((view) => views.push(view));
assert.deepEqual(views, [null], 'a subscriber starts from the current one');
parts.createReplayVehicleOverlay();
parts.createReplayVehicleOverlay();
assert.equal(views.length, 2);
assert.equal(views[1].visible, false);
parts.hideReplayVehicleOverlay();
assert.equal(views.length, 2, 'an unchanged overlay is not sent again');
parts.destroyReplayVehicleOverlay();
assert.equal(views.at(-1), null);
parts.hideReplayVehicleOverlay();
unsubscribe();
parts.createReplayVehicleOverlay();
assert.equal(views.length, 3);
});
test('the scene chrome renders the overlay in the globe container', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const app = createApp(SceneChromeView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const store = useMissionReplayStore(pinia);
const node = () => document.querySelector('.mission-replay-vehicle-overlay');
const parts = overlays();
parts.subscribeReplayOverlay((view) => store.publish(view));
await nextTick();
assert.equal(node(), null);
parts.createReplayVehicleOverlay();
await nextTick();
const element = node();
assert.equal(element.parentElement.id, 'cesiumContainer');
assert.equal(element.hidden, true);
assert.equal(element.getAttribute('aria-hidden'), 'true');
assert.equal(element.hasAttribute('data-mode'), false);
assert.equal(
element.querySelectorAll('.mission-replay-thrust ellipse').length,
6,
);
// A frame as updateReplayVehicleOverlay presents it.
parts.state._replayVehicleOverlay = {
...parts.state._replayVehicleOverlay,
visible: true,
mode: 'ascent',
thrusting: true,
paused: false,
transform: 'translate3d(10px, 20px, 0) translate(-50%, -50%)',
rotation: '12deg',
iconOpacity: '0.5',
labelOpacity: '1',
title: 'ASCENT REPLAY · TEST',
detail: 'T+00:10',
};
store.publish(parts.state._replayVehicleOverlay);
await nextTick();
assert.equal(element.hidden, false);
assert.equal(element.dataset.mode, 'ascent');
assert.ok(element.classList.contains('is-thrusting'));
assert.equal(element.classList.contains('is-paused'), false);
assert.equal(
element.style.getPropertyValue('--mission-replay-rotation'),
'12deg',
);
assert.match(element.style.transform, /translate3d\(10px, 20px, 0(px)?\)/);
assert.equal(
element.querySelector('.mission-replay-flight-symbol').style.opacity,
'0.5',
);
assert.equal(
element.querySelector('.mission-replay-orbit-dot').style.opacity,
'0.5',
);
assert.equal(
element.querySelector('.mission-replay-overlay-callout').style.opacity,
'1',
);
assert.equal(
element.querySelector('[data-replay-overlay-title]').textContent,
'ASCENT REPLAY · TEST',
);
assert.equal(
element.querySelector('[data-replay-overlay-detail]').textContent,
'T+00:10',
);
parts.hideReplayVehicleOverlay();
await nextTick();
assert.equal(element.hidden, true);
assert.equal(element.classList.contains('is-thrusting'), false);
assert.equal(
element.dataset.mode,
'ascent',
'hiding keeps the mode, as before',
);
parts.destroyReplayVehicleOverlay();
await nextTick();
assert.equal(node(), null);
});
+18 -3
View File
@@ -53,9 +53,21 @@ export function createState({ services }) {
state._launches = [];
state._missionPanel = null;
state._missionPanelReady = false;
state._missionRoster = null;
state._missionPanelView = null;
state._missionPanelSignature = null;
state._missionPanelListeners = new Set();
state._missionRosterView = null;
state._missionRosterSignature = null;
state._missionTelemetry = null;
state._missionPanelScrollRequest = 0;
state._missionRosterHoverTimer = null;
@@ -65,9 +77,10 @@ export function createState({ services }) {
state._missionHoverReticleImage = null;
// The replay vehicle overlay as its view renders it; null without one.
state._replayVehicleOverlay = null;
state._replayVehicleOverlayText = '';
state._replayVehicleOverlayListeners = new Set();
state._animationStarts = new Map();
@@ -99,6 +112,8 @@ export function createState({ services }) {
state._replayPaused = false;
state._replayTransportLabel = null;
state._replayPausedAtMs = null;
state._replaySpeed = 1;
+20
View File
@@ -1,3 +1,5 @@
import { getLocale } from '../../i18n/index.js';
export function createPresentation({
state: layerState,
services,
@@ -10,9 +12,26 @@ export function createPresentation({
return layerState._acceptedCatalogSnapshot;
}
/** Build the station categories, labelled in the current locale. */
function buildCategories() {
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(layerState._stations)
.map((category) => Object.freeze(category)),
);
layerState._categoriesLocale = getLocale();
}
/** Return an immutable snapshot consumed by the right-rail UI. */
function getRadioUIState() {
// Labels follow a language switch the next time the state is read.
if (
layerState._categories.length &&
layerState._categoriesLocale !== getLocale()
)
buildCategories();
const visible = parts.queries.visibleStations();
const selected = parts.queries.selectedStation();
return Object.freeze({
@@ -98,6 +117,7 @@ export function createPresentation({
return {
getRadioAcceptedCatalogSnapshot,
getRadioUIState,
buildCategories,
emitState,
subscribeToRadio,
subscribeToRadioPlaybackControls,
+1 -5
View File
@@ -295,11 +295,7 @@ export function createRendering({
layerState._stationById = new Map(
stations.map((station) => [station.id, station]),
);
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(stations)
.map((category) => Object.freeze(category)),
);
parts.presentation.buildCategories();
if (
layerState._filter === DEFAULT_RADIO_FILTER &&
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
+1
View File
@@ -38,6 +38,7 @@ export function createState({ services }) {
layerState._stationById = new Map();
layerState._categories = [];
layerState._categoriesLocale = null;
layerState._renderById = new Map();
+12 -17
View File
@@ -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) {
+153
View File
@@ -0,0 +1,153 @@
/**
* A weather source can describe its own products (credit, units, coverage,
* legend) in the optional `display` object of a product manifest. Each field
* is checked on its own: an absent or invalid field is dropped, and the layer
* keeps its built-in text for it. Units and legend describe the same scale,
* so they are kept only together. Accepted text is source data, shown as is
* rather than translated.
*/
const TEXT_LIMITS = Object.freeze({
source: 60,
credit: 200,
units: 40,
coverage: 48,
info: 600,
});
const LEGEND_MIN = 2;
const LEGEND_MAX = 16;
const LEGEND_LABEL_MAX = 16;
// Control and line-break characters, plus invisible or format characters:
// soft hyphen, combining grapheme joiner, Arabic letter mark, Mongolian vowel
// separator, zero-width, direction-mark, bidi embedding/override/isolate,
// variation selector, byte-order and tag characters.
const CONTROL =
/[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufe00-\ufe0f\ufeff\u{e0000}-\u{e007f}]/u;
// A number label must read as a plain decimal, as the legend formats it.
const DECIMAL = /^-?\d+(?:\.\d+)?$/;
// A label the legend formats as a number: a plain decimal, maybe with "+".
const SCALE_NUMBER = /^-?(\d+(?:\.(\d+))?)\+?$/;
const COLOR = /^#[0-9a-f]{6}$/i;
const EMPTY = Object.freeze({});
const cache = new WeakMap();
function text(value, max) {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed && trimmed.length <= max && !CONTROL.test(trimmed)
? trimmed
: undefined;
}
/**
* Whether a label the legend formats as a number reads back exactly as given:
* within the safe integer range and with no digits lost to rounding, so
* '9999999999999999' (shown as 10000000000000000) and '007' are refused.
* Other text is shown as given.
*/
function exactScaleLabel(label) {
const match = SCALE_NUMBER.exec(label);
if (!match) return true;
const magnitude = Number(match[1]);
return (
magnitude <= Number.MAX_SAFE_INTEGER &&
magnitude.toFixed(match[2]?.length ?? 0) === match[1]
);
}
function number(value) {
if (!Number.isFinite(value)) return undefined;
const label = String(value);
return DECIMAL.test(label) && label.length <= LEGEND_LABEL_MAX
? label
: undefined;
}
function legend(value) {
if (
!Array.isArray(value) ||
value.length < LEGEND_MIN ||
value.length > LEGEND_MAX
)
return undefined;
const stops = [];
for (const stop of value) {
if (!stop || typeof stop !== 'object') return undefined;
const label =
typeof stop.label === 'number'
? number(stop.label)
: text(stop.label, LEGEND_LABEL_MAX);
if (label === undefined || !exactScaleLabel(label)) return undefined;
if (typeof stop.color !== 'string' || !COLOR.test(stop.color))
return undefined;
stops.push(Object.freeze([label, stop.color]));
}
return Object.freeze(stops);
}
function extent(value) {
if (!value || typeof value !== 'object') return undefined;
const { west, south, east, north } = value;
if (
![west, south, east, north].every(Number.isFinite) ||
west < -180 ||
west > 180 ||
east < -180 ||
east > 180 ||
south < -90 ||
north > 90 ||
south >= north ||
// No width: one meridian, including 180 to -180 across the antimeridian.
west === east ||
west - east >= 360
)
return undefined;
// West greater than east crosses the antimeridian.
return Object.freeze({ west, south, east, north });
}
/**
* The valid display fields of a weather manifest; `{}` when it has none.
* @param {object|null|undefined} manifest A validated weather snapshot.
* @returns {{ source?: string, credit?: string, units?: string,
* coverage?: string, info?: string,
* legend?: ReadonlyArray<readonly [string, string]>,
* extent?: { west: number, south: number, east: number, north: number } }}
*/
export function weatherDisplay(manifest) {
const display = manifest?.display;
if (!display || typeof display !== 'object' || Array.isArray(display))
return EMPTY;
const known = cache.get(manifest);
if (known) return known;
const result = {};
for (const [key, max] of Object.entries(TEXT_LIMITS)) {
const value = text(display[key], max);
if (value !== undefined) result[key] = value;
}
const stops = legend(display.legend);
if (stops && result.units !== undefined) result.legend = stops;
else delete result.units;
const box = extent(display.extent);
if (box) result.extent = box;
Object.freeze(result);
cache.set(manifest, result);
return result;
}
/** Whether a longitude/latitude falls inside an extent, across the antimeridian too. */
export function insideExtent({ west, south, east, north }, lon, lat) {
if (lat < south || lat > north) return false;
return west < east ? lon >= west && lon <= east : lon >= west || lon <= east;
}
/** Plain text for a Cesium credit, which otherwise reads its text as HTML. */
export function escapeCreditText(value) {
return value.replace(
/[&<>"']/g,
(ch) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[
ch
],
);
}
+939
View File
@@ -0,0 +1,939 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { GeographicTilingScheme } from 'cesium';
import { createWeatherLayer } from './index.js';
import { createWeatherRendering } from './rendering.js';
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
import { createShellCesium, createShellScene } from './shellFixture.mjs';
import { formatScaleLabel } from '../../ui/unitLabels.js';
const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z'];
const base = {
schemaVersion: 1,
times,
latest: times[1],
bounds: { west: -180, south: -60, east: 180, north: 80 },
};
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
const quiet = () => ({
matches: false,
hidden: false,
addEventListener() {},
removeEventListener() {},
});
/**
* A layer whose renderer shows every requested frame at once. `center` is the
* map centre as [lon, lat]; `manifests` maps a product to its manifest, and
* may be changed between updates.
*/
async function shownLayer(id, manifest, params, view = {}) {
let time = null;
const flights = [];
const snapshots = [];
const manifests = { [manifest.product]: manifest };
const layer = createWeatherLayer({
id,
feed: { getSnapshot: async ({ product }) => manifests[product] },
documentRef: quiet(),
eventTarget: quiet(),
matchMedia: () => quiet(),
createRendering: () => ({
rehome() {},
setAlpha() {},
setHidden() {},
updateSnapshot(snapshot) {
snapshots.push(snapshot);
},
async setFrame(_snapshot, selected) {
time = selected;
return true;
},
clear() {
time = null;
},
getDiagnostics: () => ({ time, loading: false, error: null }),
}),
});
layer.init({
camera: {
moveEnd: { addEventListener: () => () => {} },
flyTo: ({ destination }) => flights.push(destination),
pickEllipsoid: () =>
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined,
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
});
if (params) layer.setParams(params);
layer.enable();
await layer.update();
await flush();
layer.attachShellServices({ runNavigation: (navigate) => navigate() });
return { layer, flights, manifests, snapshots };
}
const PRODUCTS = [
['weather-radar', 'radar', undefined],
['weather-lightning', 'lightning', undefined],
['weather-satellite', 'clouds-regional', undefined],
['weather-satellite', 'clouds', { product: 'clouds' }],
];
const INVALID = {
source: 42,
credit: '',
units: 'x'.repeat(41),
coverage: 'line one\nline two',
info: ['not', 'text'],
legend: [
{ label: '1', color: '#ffffff' },
{ label: '2', color: 'red' },
],
extent: { west: 0, south: 10, east: 20, north: 5 },
};
const SATELLITE_TITLE =
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.';
const SATELLITE_CREDIT =
'NOAA nowCOAST · NESDIS GOES / global satellite partners';
// Today's built-in description of each product, written out rather than
// derived from the code under test.
const BUILT_IN = {
radar: {
units: 'dBZ',
coverage: 'CONUS',
legend: [
['10', '#00ecec'],
['20', '#0000f6'],
['30', '#00c800'],
['40', '#ffff00'],
['50', '#ff9000'],
['60', '#dc0000'],
['70', '#ff00ff'],
['80', '#05ede0'],
].map(([label, color]) => ({
label,
color,
blurb: `${label} dBZ radar reflectivity`,
})),
heading: 'RADAR REFLECTIVITY · dBZ',
infoCoverage: 'Contiguous US · gaps ≠ no rain',
infoTitle:
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
chip: 'View US radar',
focus: [-180, -60, 180, 80],
credit: 'NOAA nowCOAST · NWS/OAR MRMS',
},
lightning: {
units: 'strikes/km²/min ×10³',
coverage: 'Americas + Pacific',
legend: [
['0.1', '#FFFFCC'],
['1', '#FFA400'],
['5', '#FF4500'],
['10', '#FF0000'],
['50', '#FF00FF'],
['100', '#4000C0'],
['200', '#00C7FF'],
['300+', '#00FF00'],
].map(([label, color]) => ({
label,
color,
blurb: `${label} strikes/km²/min ×10³ (15-minute density)`,
})),
heading: 'LIGHTNING DENSITY · 15 min accumulation',
infoCoverage:
'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³',
infoTitle:
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
chip: 'View Americas & Pacific',
focus: [110, -25, 0, 80],
credit: 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360',
},
'clouds-regional': {
units: '',
coverage: 'North America',
legend: [],
heading: 'GOES INFRARED · ~5 min',
infoCoverage: 'North America · infrared imagery',
infoTitle: SATELLITE_TITLE,
chip: 'View coverage',
focus: [-180, -60, 180, 80],
credit: SATELLITE_CREDIT,
},
clouds: {
units: '',
coverage: 'Global · 60°S–60°N',
legend: [],
heading: 'GLOBAL INFRARED · hourly',
infoCoverage: '60°S–60°N · typically 2–3 h delayed',
infoTitle: SATELLITE_TITLE,
chip: 'View coverage',
focus: [-180, -60, 180, 80],
credit: SATELLITE_CREDIT,
},
};
/** Assert the built-in description of a product, field by field. */
function assertBuiltIn(layer, product) {
const expected = BUILT_IN[product];
const controls = layer.getRowControls();
assert.equal(controls.summary.units, expected.units);
assert.equal(controls.summary.unitsVerbatim, undefined);
assert.equal(controls.summary.coverage, expected.coverage);
assert.deepEqual(controls.legend, expected.legend);
assert.ok(
controls.info.startsWith(`${expected.heading}\n`),
`${product} heading: ${controls.info}`,
);
assert.ok(
controls.info.endsWith(`\n${expected.infoCoverage}`),
`${product} coverage: ${controls.info}`,
);
assert.equal(controls.infoTitle, expected.infoTitle);
assert.equal(
controls.chips.find(({ id }) => id === 'coverage').label,
expected.chip,
);
const stats = layer.getStats();
assert.equal(stats.source, 'NOAA nowCOAST');
assert.equal(stats.sourceVerbatim, undefined);
}
for (const [id, product, params] of PRODUCTS) {
test(`${product}: a manifest without display metadata keeps today's description`, async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer, flights } = await shownLayer(
id,
{ ...base, product },
params,
);
assertBuiltIn(layer, product);
layer.setParams({ focus: true });
assert.deepEqual(
flights[0],
Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus),
);
layer.destroy();
});
}
for (const [id, product, params] of PRODUCTS) {
test(`${product}: empty or wholly invalid display metadata changes nothing`, async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const plain = await shownLayer(id, { ...base, product }, params);
const empty = await shownLayer(
id,
{ ...base, product, display: {} },
params,
);
const invalid = await shownLayer(
id,
{ ...base, product, display: INVALID },
params,
);
const expected = plain.layer.getRowControls();
assert.deepEqual(empty.layer.getRowControls(), expected);
assert.deepEqual(invalid.layer.getRowControls(), expected);
assertBuiltIn(invalid.layer, product);
for (const h of [plain, invalid]) h.layer.setParams({ focus: true });
assert.deepEqual(invalid.flights, plain.flights);
for (const h of [plain, empty, invalid]) h.layer.destroy();
});
}
const EXAMPLE = {
source: 'Example network',
credit: 'Example network · flash density',
units: 'flashes/km²/min',
coverage: '52°S–52°N',
info: 'Flash density from an example network. Not a safety warning.',
legend: [
{ label: 0.5, color: '#112233' },
{ label: '2', color: '#445566' },
{ label: '8+', color: '#AABBCC' },
],
extent: { west: -180, south: -52, east: 180, north: 52 },
};
test('valid display metadata describes the product in the row, legend and stats', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer, flights } = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: EXAMPLE,
});
const controls = layer.getRowControls();
assert.equal(controls.summary.units, 'flashes/km²/min');
assert.equal(controls.summary.unitsVerbatim, true);
assert.equal(controls.summary.coverage, '52°S–52°N');
assert.deepEqual(controls.legend, [
{ label: '0.5', color: '#112233', blurb: '0.5 flashes/km²/min' },
{ label: '2', color: '#445566', blurb: '2 flashes/km²/min' },
{ label: '8+', color: '#AABBCC', blurb: '8+ flashes/km²/min' },
]);
assert.match(controls.info, /^LIGHTNING DENSITY · 15 min accumulation\n/);
assert.match(controls.info, /\n52°S–52°N\nColor: flashes\/km²\/min$/);
assert.equal(controls.infoTitle, EXAMPLE.info);
assert.equal(
controls.chips.find(({ id }) => id === 'coverage').label,
'View coverage',
);
const stats = layer.getStats();
assert.equal(stats.source, 'Example network');
assert.equal(stats.sourceVerbatim, true);
layer.setParams({ focus: true });
assert.deepEqual(
flights[0],
Cesium.Rectangle.fromDegrees(-180, -52, 180, 52),
);
layer.destroy();
});
test('units and legend apply only as a valid pair', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const legend = [
{ label: 1, color: '#000000' },
{ label: 10, color: '#ffffff' },
];
for (const [id, product] of [
['weather-lightning', 'lightning'],
['weather-radar', 'radar'],
])
for (const display of [
{ units: 'mm/h' },
{ legend },
{ units: 'mm/h', legend: [legend[0]] },
{ units: 'x'.repeat(41), legend },
]) {
const { layer } = await shownLayer(id, { ...base, product, display });
assertBuiltIn(layer, product);
layer.destroy();
}
const radar = await shownLayer('weather-radar', {
...base,
product: 'radar',
display: { coverage: 'Europe', units: 'mm/h', legend },
});
const controls = radar.layer.getRowControls();
assert.equal(controls.summary.coverage, 'Europe');
assert.equal(controls.summary.units, 'mm/h');
assert.equal(controls.summary.unitsVerbatim, true);
assert.deepEqual(controls.legend, [
{ label: '1', color: '#000000', blurb: '1 mm/h' },
{ label: '10', color: '#ffffff', blurb: '10 mm/h' },
]);
assert.match(controls.info, /^RADAR · mm\/h\n/);
assert.match(controls.info, /\nEurope$/);
radar.layer.destroy();
const clouds = await shownLayer('weather-satellite', {
...base,
product: 'clouds-regional',
display: {
units: 'K',
legend: [
{ label: 'warm', color: '#000000' },
{ label: 'cold', color: '#ffffff' },
],
},
});
assert.equal(clouds.layer.getRowControls().summary.units, 'K');
assert.deepEqual(clouds.layer.getRowControls().legend, [
{ label: 'warm', color: '#000000', blurb: 'warm K' },
{ label: 'cold', color: '#ffffff', blurb: 'cold K' },
]);
clouds.layer.destroy();
});
test('other display fields fall back on their own', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const { layer } = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: { ...INVALID, coverage: 'Example coverage', source: 'Example' },
});
const controls = layer.getRowControls();
assert.equal(controls.summary.coverage, 'Example coverage');
assert.equal(controls.summary.units, BUILT_IN.lightning.units);
assert.deepEqual(controls.legend, BUILT_IN.lightning.legend);
assert.equal(controls.infoTitle, BUILT_IN.lightning.infoTitle);
assert.equal(layer.getStats().source, 'Example');
layer.destroy();
});
test('the map centre is checked against the source extent when one is given', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const OUTSIDE = 'Map center is outside source coverage';
const outside = (layer) => layer.getRowControls().info.includes(OUTSIDE);
const at = async (id, product, display, center) => {
const h = await shownLayer(
id,
{ ...base, product, ...(display ? { display } : {}) },
undefined,
{ center },
);
const result = outside(h.layer);
if (result)
assert.equal(
h.layer.getRowControls().summary.status,
'Map center outside coverage',
);
h.layer.destroy();
return result;
};
// Without an extent: the manifest bounds, plus lightning's built-in gap.
assert.equal(
await at('weather-lightning', 'lightning', null, [50, 10]),
true,
);
assert.equal(
await at('weather-lightning', 'lightning', null, [-100, 10]),
false,
);
assert.equal(await at('weather-radar', 'radar', null, [50, 10]), false);
assert.equal(await at('weather-radar', 'radar', null, [50, -70]), true);
// An extent replaces the built-in gap.
const global = { extent: { west: -180, south: -52, east: 180, north: 52 } };
assert.equal(
await at('weather-lightning', 'lightning', global, [50, 10]),
false,
);
assert.equal(
await at('weather-lightning', 'lightning', global, [50, 60]),
true,
);
const europe = { extent: { west: -10, south: 35, east: 30, north: 70 } };
assert.equal(await at('weather-radar', 'radar', europe, [10, 50]), false);
assert.equal(await at('weather-radar', 'radar', europe, [-50, 50]), true);
assert.equal(await at('weather-radar', 'radar', europe, [10, 30]), true);
// West greater than east crosses the antimeridian.
const pacific = { extent: { west: 150, south: -30, east: -120, north: 60 } };
for (const [center, expected] of [
[[170, 0], false],
[[-150, 0], false],
[[0, 0], true],
[[-100, 0], true],
[[170, 70], true],
])
assert.equal(
await at('weather-radar', 'radar', pacific, center),
expected,
`centre ${center}`,
);
});
test('switching to a product without display metadata restores its built-ins', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const h = await shownLayer('weather-satellite', {
...base,
product: 'clouds-regional',
display: EXAMPLE,
});
h.manifests.clouds = { ...base, product: 'clouds' };
assert.equal(h.layer.getStats().source, 'Example network');
h.layer.setParams({ product: 'clouds' });
await flush();
assertBuiltIn(h.layer, 'clouds');
h.layer.setParams({ product: 'clouds-regional' });
await flush();
assert.equal(h.layer.getStats().source, 'Example network');
assert.equal(h.layer.getRowControls().summary.coverage, '52°S–52°N');
h.layer.destroy();
});
test('a refresh that drops display metadata restores the built-ins', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const h = await shownLayer('weather-lightning', {
...base,
product: 'lightning',
display: EXAMPLE,
});
assert.equal(h.layer.getStats().source, 'Example network');
const plain = { ...base, product: 'lightning' };
h.manifests.lightning = plain;
await h.layer.update();
assertBuiltIn(h.layer, 'lightning');
assert.equal(h.snapshots.at(-1), plain, 'the renderer sees the refresh');
h.layer.destroy();
});
test('display validation bounds text, legend shape, colours and extents', () => {
const read = (display) => weatherDisplay({ display });
const scale = (legend) => read({ units: 'u', legend }).legend;
assert.deepEqual(read(undefined), {});
assert.deepEqual(read(['credit']), {});
assert.deepEqual(read({ coverage: ' Europe ' }), { coverage: 'Europe' });
assert.deepEqual(read({ coverage: ' ' }), {});
assert.deepEqual(read({ credit: 'a
b' }), {});
assert.deepEqual(read({ credit: 'x'.repeat(200) }).credit.length, 200);
assert.deepEqual(read({ credit: 'x'.repeat(201) }), {});
assert.deepEqual(read({ info: 'x'.repeat(601) }), {});
// Invisible and direction-changing characters are rejected in every field.
for (const code of [
0x00ad, 0x034f, 0x061c, 0x180e, 0x200b, 0x200d, 0x200e, 0x200f, 0x202a,
0x202d, 0x202e, 0x2060, 0x2066, 0x2069, 0xfe00, 0xfe0f, 0xfeff, 0xe0000,
0xe0001, 0xe0041, 0xe007f,
]) {
const ch = String.fromCodePoint(code);
const value = `a${ch}b`;
for (const key of ['source', 'credit', 'units', 'coverage', 'info'])
assert.equal(
read({ [key]: value, legend: [{ label: 1, color: '#000000' }] })[key],
undefined,
`${key} with U+${ch.codePointAt(0).toString(16)}`,
);
assert.equal(
scale([
{ label: '0', color: '#000000' },
{ label: value, color: '#000000' },
]),
undefined,
);
}
assert.equal(read({ coverage: 'a b · c–d' }).coverage, 'a b · c–d');
const stop = (label, color = '#000000') => ({ label, color });
assert.equal(scale([stop('1')]), undefined);
assert.equal(
scale(Array.from({ length: 17 }, (_, i) => stop(String(i)))),
undefined,
);
for (const bad of [
stop(Number.NaN),
stop(Infinity),
stop(0.1 + 0.2),
stop(1e21),
stop(1e-7),
stop(''),
stop('x'.repeat(17)),
stop('1', '#fff'),
stop('1', 'rgb(0,0,0)'),
stop('1', '#00000g'),
null,
'1',
])
assert.equal(scale([stop('0'), bad]), undefined);
// A number label must read back exactly as the legend formats it.
for (const bad of [
'9999999999999999',
'9007199254740992',
'9007199254740992+',
'007',
'00.5',
9007199254740992,
])
assert.equal(scale([stop('0'), stop(bad)]), undefined, String(bad));
assert.deepEqual(
scale([
stop('9007199254740991'),
stop(-900719925474099),
stop('-0.50'),
stop('1.25+'),
stop('123456.789012345'),
]),
[
['9007199254740991', '#000000'],
['-900719925474099', '#000000'],
['-0.50', '#000000'],
['1.25+', '#000000'],
['123456.789012345', '#000000'],
],
);
for (const label of ['9007199254740991', '-0.50', '1.25+', '0.001'])
assert.equal(formatScaleLabel(label), label);
assert.equal(formatScaleLabel('9999999999999999'), '10000000000000000');
assert.deepEqual(scale([stop(0), stop(-2.5), stop('1', '#ABCDEF')]), [
['0', '#000000'],
['-2.5', '#000000'],
['1', '#ABCDEF'],
]);
// A legend needs its units and units need their legend.
assert.deepEqual(read({ units: 'u' }), {});
assert.deepEqual(read({ legend: [stop(0), stop(1)] }), {});
for (const bad of [
{ west: -10, south: 0, east: 10 },
{ west: -10, south: 0, east: 10, north: '5' },
{ west: -181, south: 0, east: 10, north: 5 },
{ west: -10, south: -91, east: 10, north: 5 },
{ west: -10, south: 5, east: 10, north: 5 },
{ west: -10, south: 6, east: 10, north: 5 },
{ west: 10, south: 0, east: 10, north: 5 },
{ west: 180, south: 0, east: -180, north: 5 },
{ west: -180, south: 0, east: -180, north: 5 },
])
assert.equal(read({ extent: bad }).extent, undefined, JSON.stringify(bad));
for (const good of [
{ west: -180, south: -90, east: 180, north: 90 },
{ west: 170, south: 0, east: -180, north: 5 },
])
assert.deepEqual(read({ extent: good }).extent, good);
const wrap = read({ extent: { west: 110, south: -25, east: 0, north: 80 } });
assert.equal(insideExtent(wrap.extent, 150, 10), true);
assert.equal(insideExtent(wrap.extent, -100, 10), true);
assert.equal(insideExtent(wrap.extent, 50, 10), false);
assert.equal(insideExtent(wrap.extent, -100, 81), false);
assert.equal(
escapeCreditText(`<b>"A" & 'B'</b>`),
'&lt;b&gt;&quot;A&quot; &amp; &#39;B&#39;&lt;/b&gt;',
);
});
function mockCanvas() {
const canvas = { width: 0, height: 0 };
canvas.getContext = () => ({
setTransform() {},
drawImage() {},
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
putImageData() {},
});
return canvas;
}
/** A globe viewer whose imagery collection records providers. */
function globeViewer() {
const providers = [];
const layers = [];
const collection = {
addImageryProvider(provider) {
providers.push(provider);
const layer = { imageryProvider: provider, alpha: 1, show: true };
layers.push(layer);
return layer;
},
contains: (layer) => layers.includes(layer),
remove(layer) {
layers.splice(layers.indexOf(layer), 1);
return true;
},
get length() {
return layers.length;
},
get: (index) => layers[index],
indexOf: (layer) => layers.indexOf(layer),
lower() {},
raise() {},
raiseToTop() {},
isDestroyed: () => false,
};
const listeners = new Set();
const viewer = {
imageryLayers: collection,
camera: {
moveEnd: { addEventListener: () => () => {} },
flyTo() {},
},
scene: {
postRender: {
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
},
primitives: createShellScene().primitives,
globe: { tilesLoaded: true },
requestRender() {},
},
};
class Provider {
constructor(options) {
this.options = options;
this.tilingScheme = new GeographicTilingScheme();
this.errorEvent = new Cesium.Event();
}
requestImage() {
return undefined;
}
}
const cesium = {
...createShellCesium(),
UrlTemplateImageryProvider: Provider,
GeographicTilingScheme,
Event: Cesium.Event,
Credit: Cesium.Credit,
Rectangle: Cesium.Rectangle,
};
const options = {
cesium,
fetchImpl: async () => ({
ok: true,
headers: new Headers(),
arrayBuffer: async () => new ArrayBuffer(1),
}),
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
createCanvas: mockCanvas,
};
const settle = async () => {
await flush();
for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn();
await flush();
};
return { viewer, providers, options, settle };
}
/** Show a frame of `manifest` and return the rendering and its globe. */
async function shownFrame(manifest) {
const globe = globeViewer();
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
});
const shown = rendering.setFrame(manifest, times[1]);
await globe.settle();
assert.equal(await shown, true);
return { ...globe, rendering };
}
const creditOf = (providers) => providers.at(-1).credit.html;
test('the imagery credit uses the source credit as plain text, else the built-in one', async () => {
for (const product of ['radar', 'lightning', 'clouds-regional', 'clouds']) {
for (const display of [undefined, { credit: 42 }]) {
const h = await shownFrame({
...base,
product,
...(display ? { display } : {}),
});
assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product);
h.rendering.clear();
}
const h = await shownFrame({
...base,
product,
display: { credit: 'Example <network> & partners' },
});
assert.equal(
creditOf(h.providers),
'Example &lt;network&gt; &amp; partners',
);
h.rendering.clear();
}
});
test('a refreshed manifest updates the credit of the frame already shown', async () => {
for (const product of ['lightning', 'clouds']) {
const h = await shownFrame({
...base,
product,
display: { credit: 'First credit' },
});
assert.equal(creditOf(h.providers), 'First credit');
const again = (display) =>
h.rendering.setFrame(
{ ...base, product, ...(display ? { display } : {}) },
times[1],
);
assert.equal(await again({ credit: 'Second credit' }), true);
assert.equal(h.providers.length, 1, 'the frame is not restaged');
assert.equal(creditOf(h.providers), 'Second credit');
assert.equal(await again(), true);
assert.equal(creditOf(h.providers), BUILT_IN[product].credit);
h.rendering.updateSnapshot({
...base,
product,
display: { credit: 'Third credit' },
});
assert.equal(creditOf(h.providers), 'Third credit');
// Another product's manifest leaves this frame alone.
h.rendering.updateSnapshot({
...base,
product: 'radar',
display: { credit: 'Other' },
});
assert.equal(creditOf(h.providers), 'Third credit');
h.rendering.clear();
}
});
test('a refreshed manifest leaves the shown snapshot for a host switch to restage', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const staged = [];
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame(snapshot, time) {
staged.push([snapshot, time]);
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
await globe.settle();
assert.equal(await shown, true);
const refreshed = {
...first,
times: [times[1]],
bounds: { west: -10, south: 0, east: 10, north: 5 },
display: { credit: 'Second credit' },
};
rendering.updateSnapshot(refreshed);
assert.equal(creditOf(globe.providers), 'Second credit');
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
assert.deepEqual(staged, [[first, times[0]]]);
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
await globe.settle();
assert.equal(globe.providers.length, 2);
const { west, south, east, north } = first.bounds;
assert.ok(
Cesium.Rectangle.equals(
globe.providers.at(-1).options.rectangle,
Cesium.Rectangle.fromDegrees(west, south, east, north),
),
'the restaged frame keeps the bounds it was staged from',
);
assert.equal(creditOf(globe.providers), 'Second credit');
rendering.clear();
});
test('a refresh during a host-switch restage keeps the newest credit', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame() {
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
await globe.settle();
assert.equal(await shown, true);
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
rendering.updateSnapshot({ ...first, display: { credit: 'Third credit' } });
await globe.settle();
assert.equal(globe.providers.length, 2);
assert.equal(creditOf(globe.providers), 'Third credit');
rendering.clear();
});
test('a refresh while a frame loads survives a later host switch', async () => {
const globe = globeViewer();
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
getHost: () => host,
createShell: ({ product }) => ({
product,
setAlpha() {},
setHidden() {},
rehome: () => false,
cancelPrefetch() {},
clear() {},
async setFrame() {
return true;
},
}),
});
const first = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const shown = rendering.setFrame(first, times[0]);
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
await globe.settle();
assert.equal(await shown, true);
assert.equal(creditOf(globe.providers), 'Second credit');
host = { collection: {}, kind: 'tileset' };
assert.equal(rendering.rehome(), true);
await flush();
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
assert.equal(rendering.rehome(), true);
await globe.settle();
assert.equal(globe.providers.length, 2);
assert.equal(creditOf(globe.providers), 'Second credit');
rendering.clear();
});
test('a refresh before a frame is installed gives it the newest credit', async () => {
for (const product of ['lightning', 'clouds']) {
const globe = globeViewer();
const rendering = createWeatherRendering({
viewer: globe.viewer,
...globe.options,
});
const first = { ...base, product, display: { credit: 'First credit' } };
const shown = rendering.setFrame(first, times[1]);
rendering.updateSnapshot({
...first,
display: { credit: 'Second credit' },
});
await globe.settle();
assert.equal(await shown, true);
assert.equal(creditOf(globe.providers), 'Second credit', product);
rendering.clear();
}
});
test('a layer refresh with the same latest frame updates the imagery credit', async (t) => {
t.mock.method(Date, 'now', () => Date.parse(times[1]));
const globe = globeViewer();
let manifest = {
...base,
product: 'lightning',
display: { credit: 'First credit' },
};
const layer = createWeatherLayer({
id: 'weather-lightning',
feed: { getSnapshot: async () => manifest },
documentRef: quiet(),
eventTarget: quiet(),
matchMedia: () => quiet(),
createRendering: (args) =>
createWeatherRendering({ ...args, ...globe.options }),
});
layer.init(globe.viewer);
layer.enable();
const first = layer.update();
await globe.settle();
await first;
assert.equal(creditOf(globe.providers), 'First credit');
manifest = { ...manifest, display: { credit: 'Second credit' } };
await layer.update();
assert.equal(globe.providers.length, 1, 'the frame is not restaged');
assert.equal(creditOf(globe.providers), 'Second credit');
manifest = { ...base, product: 'lightning' };
await layer.update();
assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit);
layer.destroy();
});
+85 -49
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium';
import { createWeatherRendering } from './rendering.js';
import { imageryHostStatus } from './imageryHost.js';
import { weatherDisplay, insideExtent } from './display.js';
import {
RADAR_MAX_GAP_MS,
REGIONAL_INFRARED_MAX_GAP_MS,
@@ -371,6 +372,8 @@ export function createWeatherLayer({
}
manifest = snapshot;
error = null;
// The shown frame may stay; its credit follows the refreshed manifest.
rendering?.updateSnapshot?.(snapshot);
if (clock) {
await clock.refresh();
if (
@@ -467,9 +470,11 @@ export function createWeatherLayer({
manifest?.bounds &&
runNavigation
) {
const b = lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds;
const b =
weatherDisplay(manifest).extent ??
(lightning
? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds);
runNavigation(() =>
viewer.camera.flyTo({
destination: cesium.Rectangle.fromDegrees(
@@ -526,6 +531,8 @@ export function createWeatherLayer({
getRowControls() {
const shared = clock?.getState();
const followLatest = isLatest();
// Source-described text is data: shown as is, never translated.
const display = weatherDisplay(manifest);
const missing =
noFrame &&
shared?.mode === 'history' &&
@@ -592,7 +599,9 @@ export function createWeatherLayer({
lon > b.east ||
lat < b.south ||
lat > b.north ||
(lightning && lon > 0 && lon < 110));
(display.extent
? !insideExtent(display.extent, lon, lat)
: lightning && lon > 0 && lon < 110));
let detail = time
? t('{state} · {time} · {lag}', {
state: followLatest ? t('Observed') : t('History'),
@@ -606,7 +615,9 @@ export function createWeatherLayer({
else if (relation === 'nearest')
detail = t('{detail} · nearest', { detail });
const infoHeading = radar
? t('RADAR REFLECTIVITY · dBZ')
? display.units !== undefined
? t('RADAR · {units}', { units: display.units })
: t('RADAR REFLECTIVITY · dBZ')
: lightning
? t('LIGHTNING DENSITY · 15 min accumulation')
: product === 'clouds'
@@ -631,13 +642,24 @@ export function createWeatherLayer({
: [t('Observation: unavailable'), loading ? t('loading') : '']
.filter(Boolean)
.join(' · ');
const infoCoverage = radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery');
const coverageLine =
display.coverage ??
(radar
? t('Contiguous US · gaps ≠ no rain')
: lightning
? t('Americas + Pacific · not individual strikes')
: product === 'clouds'
? t('60°S–60°N · typically 2–3 h delayed')
: t('North America · infrared imagery'));
const infoCoverage = lightning
? `${coverageLine}\n${
display.units === undefined
? t('Color: strikes/km²/min ×10³')
: t('Color: {units}', { units: display.units })
}`
: coverageLine;
const stops =
display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []);
const infoMotion = motion?.matches
? clock
? t('Reduced motion · history playback unavailable')
@@ -651,13 +673,15 @@ export function createWeatherLayer({
: lightning
? t('Lightning density · 15 min')
: t('Satellite clouds'),
coverage: radar
? 'CONUS'
: lightning
? t('Americas + Pacific')
: product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America'),
coverage:
display.coverage ??
(radar
? 'CONUS'
: lightning
? t('Americas + Pacific')
: product === 'clouds'
? t('Global · 60°S–60°N')
: t('North America')),
shownTime: time,
maxGapMinutes: maxGap() / 60_000,
detail,
@@ -674,7 +698,11 @@ export function createWeatherLayer({
: outside
? t('Map center outside coverage')
: null),
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
units:
display.units ??
(radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : ''),
// Source units are data: the panel shows them untranslated.
...(display.units !== undefined ? { unitsVerbatim: true } : {}),
},
chips: [
...(satellite
@@ -723,43 +751,47 @@ export function createWeatherLayer({
})),
{
id: 'coverage',
label: radar
? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
label:
display.coverage !== undefined || display.extent !== undefined
? t('View coverage')
: radar
? t('View US radar')
: lightning
? t('View Americas & Pacific')
: t('View coverage'),
disabled: !manifest || !runNavigation,
params: { focus: true },
},
],
legend:
radar || lightning
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
label: String(label),
color,
blurb: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
}))
: [],
legend: stops.map(([label, color]) => ({
label: String(label),
color,
blurb: display.legend
? `${formatScaleLabel(label)} ${display.units}`
: lightning
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
value: formatScaleLabel(label),
})
: t('{value} dBZ radar reflectivity', {
value: formatScaleLabel(label),
}),
})),
info: hostHidden
? shownHost
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
infoTitle: lightning
? t(
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
)
: radar
infoTitle:
display.info ??
(lightning
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
)
: t(
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
),
: radar
? t(
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
)
: t(
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
)),
};
controls.summary.settings = [
...(satellite
@@ -805,6 +837,7 @@ export function createWeatherLayer({
listener = typeof value === 'function' ? value : null;
},
getStats() {
const { source } = weatherDisplay(manifest);
return {
count: shownTime() ? 1 : 0,
countLabel: isLatest() ? t('Observed') : t('History'),
@@ -812,7 +845,10 @@ export function createWeatherLayer({
loading,
error: error || rendering?.getDiagnostics().error || null,
stale: Boolean(manifest?.stale || observationDelayed()),
source: 'NOAA nowCOAST',
...(source === undefined
? { source: 'NOAA nowCOAST' }
: // Source text is data: the panel shows it untranslated.
{ source, sourceVerbatim: true }),
observedAt: shownTime(),
};
},
+83 -13
View File
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
import { imageryHostStatus } from './imageryHost.js';
import { createRasterTileProvider } from './rasterTiles.js';
import { createWeatherShell } from './shellRendering.js';
import { weatherDisplay, escapeCreditText } from './display.js';
import { msg } from '../../i18n/index.js';
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import {
@@ -116,6 +117,38 @@ function createGlobeRendering({
return urls;
}
function creditText(snapshot) {
const sourceCredit = weatherDisplay(snapshot).credit;
return sourceCredit !== undefined
? escapeCreditText(sourceCredit)
: snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
}
function setCredit(frame) {
const text = creditText(frame.creditSnapshot ?? frame.snapshot);
if (frame.credit?.html === text) return false;
frame.credit = new cesium.Credit(text, false);
return true;
}
/**
* Take the credit of a refreshed manifest of the same product. Each frame
* keeps the snapshot it was staged from, whose time and bounds still match,
* and remembers the refreshed one for its credit, including a frame that
* is still loading and takes its credit when installed.
*/
function updateSnapshot(snapshot) {
let changed = false;
for (const frame of new Set([current, incoming, ...retiring])) {
if (!frame || frame.product !== snapshot.product) continue;
frame.creditSnapshot = snapshot;
if (frame.credit && setCredit(frame)) changed = true;
}
if (changed) viewer.scene.requestRender();
}
function remove(frame) {
if (!frame) return;
frame.closed = true;
@@ -180,6 +213,7 @@ function createGlobeRendering({
return {
rehome,
cancelPrefetch,
updateSnapshot,
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
let job;
try {
@@ -220,7 +254,11 @@ function createGlobeRendering({
if (job && prefetchJob === job) prefetchJob = null;
}
},
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
async setFrame(
snapshot,
time,
{ signal, infrared = 'filtered', creditSnapshot } = {},
) {
signal?.throwIfAborted();
cancelPrefetch();
rehome();
@@ -234,14 +272,17 @@ function createGlobeRendering({
current.product === snapshot.product &&
current.kind === kind &&
current.infrared === infrared
)
) {
updateSnapshot(creditSnapshot ?? snapshot);
return true;
}
lastError = null;
const { west, south, east, north } = snapshot.bounds;
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
const global = snapshot.product === 'clouds';
const frame = {
snapshot,
creditSnapshot,
time,
infrared,
mosaic: global
@@ -342,14 +383,8 @@ function createGlobeRendering({
}
: undefined,
);
const credit = new cesium.Credit(
snapshot.product === 'lightning'
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
: snapshot.product === 'radar'
? 'NOAA nowCOAST · NWS/OAR MRMS'
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
false,
);
setCredit(frame);
const credit = frame.credit;
const provider = global
? createRasterTileProvider({
cesium,
@@ -373,6 +408,13 @@ function createGlobeRendering({
enablePickFeatures: false,
credit,
});
// Cesium reads a provider's credit every frame; a refreshed manifest
// can replace it without restaging the imagery.
Object.defineProperty(provider, 'credit', {
configurable: true,
enumerable: true,
get: () => frame.credit,
});
const requestImage = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (frame.closed) return undefined;
@@ -574,6 +616,8 @@ export function createWeatherRendering({
let hidden = false;
let shown = null;
let restaging = null;
// The last refreshed manifest, so a frame shown after it keeps its credit.
let refreshed = null;
// A host without imagery keeps the active renderer, which retains its frame.
const wanted = () => {
@@ -602,8 +646,13 @@ export function createWeatherRendering({
restaging = null;
if (!ok) onChange();
};
// The restaged frame takes the newest refreshed credit; a refresh while
// it loads reaches it through the globe's own updateSnapshot.
target
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
.setFrame(frame.snapshot, frame.time, {
infrared: frame.infrared,
...(target === globe ? { creditSnapshot: shown?.latest } : {}),
})
.then(settle, () => settle(false));
}
function switchHost() {
@@ -645,11 +694,31 @@ export function createWeatherRendering({
switchHost();
restaging = null;
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
const before = refreshed;
const ok = await target.setFrame(snapshot, time, options);
if (ok)
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
if (ok) {
// Keep a refresh that arrived while this frame loaded.
const latest =
refreshed !== before && refreshed?.product === snapshot.product
? refreshed
: undefined;
shown = {
snapshot,
time,
infrared: options.infrared ?? 'filtered',
...(latest ? { latest } : {}),
};
}
return ok;
},
// The shown frame keeps its staged snapshot for a restage on another
// host; only the refreshed credit is remembered and applied.
updateSnapshot(snapshot) {
refreshed = snapshot;
if (shown?.snapshot.product === snapshot.product)
shown = { ...shown, latest: snapshot };
globe.updateSnapshot(snapshot);
},
setHidden(value) {
hidden = Boolean(value);
if (hidden) restaging = null;
@@ -662,6 +731,7 @@ export function createWeatherRendering({
clear() {
restaging = null;
shown = null;
refreshed = null;
hidden = false;
shell?.clear();
shell = null;
+2 -5
View File
@@ -6,11 +6,8 @@ import {
windReadingResult,
windUnitChips,
} from './presentation.js';
import {
inspectWindAtCenter,
createWindInspectionMarker,
WIND_UNITS,
} from './inspection.js';
import { inspectWindAtCenter, WIND_UNITS } from './inspection.js';
import { createWindInspectionMarker } from './inspectionMarker.js';
import { t, msg, translateKnown } from '../../i18n/index.js';
import { unitLabel } from '../../ui/unitLabels.js';
+10 -8
View File
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
const snapshot = (model) => ({
model,
@@ -302,12 +304,12 @@ test('weather summary describes the selected forecast and count remains numeric'
});
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
const nodes = [];
// The marker the scene chrome renders, as the wind layer presents it.
const marker = () => useWindInspectionStore(applicationPinia()).marker;
let listener;
let samples = 0;
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
ownerDocument: {},
getBoundingClientRect: () => ({ left: 0, top: 0 }),
};
const viewer = {
@@ -338,9 +340,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
layer.setParams({ inspect: true });
const captured = layer.getRowControls().summary.reading;
assert.equal(samples, 1);
const retained = nodes[0];
const retained = marker().owner;
layer.setParams({ units: 'mph' });
assert.equal(nodes[0], retained);
assert.ok(marker().owner === retained);
const changed = layer.getRowControls().summary.reading;
assert.equal(changed.speed, captured.speed);
assert.equal(changed.coordinates, captured.coordinates);
@@ -348,7 +350,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
assert.equal(samples, 1, 'units do not resample');
assert.equal(layer.getRowControls().summary.result.id, 'reading');
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
assert.equal(nodes.length, 1);
assert.ok(marker());
assert.equal(typeof listener, 'function');
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
layer.setParams({ overlay: 'speed' });
@@ -368,9 +370,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
assert.equal(samples, 2, 'the next explicit read samples the new center');
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
layer.setParams({ inspect: true });
assert.equal(nodes.length, 1);
assert.ok(marker());
change();
assert.equal(nodes.length, 0);
assert.equal(marker(), null);
assert.equal(layer.getRowControls().summary.reading, null);
assert.equal(layer.getRowControls().summary.result, null);
assert.equal(listener, null);
-59
View File
@@ -112,62 +112,3 @@ export function inspectWindAtCenter(
),
};
}
/** One passive marker for the captured location; it never samples on camera movement. */
export function createWindInspectionMarker({ container, viewer, cesium }) {
let removeRender = null;
let marker = null;
const clear = () => {
removeRender?.();
removeRender = null;
marker?.remove();
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument?.createElement ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
marker = container.ownerDocument.createElement('div');
marker.className = 'gev-wind-inspection-marker';
marker.setAttribute('aria-hidden', 'true');
marker.style.cssText =
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
container.appendChild(marker);
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
marker.hidden = !visible;
if (!visible) return;
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
-52
View File
@@ -58,55 +58,3 @@ test('center inspection uses a fixed forecast reading without double-converting
'No surface reading',
);
});
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
const { createWindInspectionMarker } = await import('./inspection.js');
let listener = null;
let visible = true;
const projected = [];
const nodes = [];
const container = {
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
appendChild(node) { nodes.push(node); },
getBoundingClientRect: () => ({ left: 5, top: 10 }),
};
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
requestRender() {},
},
};
const cesium = {
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
};
const owner = createWindInspectionMarker({ container, viewer, cesium });
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
assert.equal(nodes.length, 1);
assert.match(nodes[0].style.cssText, /pointer-events:none/);
assert.equal(nodes[0].style.left, '110px');
assert.equal(nodes[0].style.top, '220px');
viewer.camera.positionWC = { moved: true };
visible = false;
listener();
assert.equal(nodes[0].hidden, true);
assert.ok(projected.every((value) => value === point));
visible = true;
listener();
assert.equal(nodes[0].hidden, false);
owner.show({ x: 4 });
assert.equal(nodes.length, 1);
owner.clear();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
assert.equal(nodes.length, 0);
assert.equal(listener, null);
});
+80
View File
@@ -0,0 +1,80 @@
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import { applicationPinia } from '../../ui/vueRuntime.js';
/**
* One passive marker for the captured location; it never samples on camera
* movement. Where the point falls is presented to the wind inspection store
* after every rendered frame, and the scene chrome renders the marker in
* `container`.
*/
export function createWindInspectionMarker({
container,
viewer,
cesium,
store = useWindInspectionStore(applicationPinia()),
}) {
const owner = {};
let removeRender = null;
let marker = null;
// Only the owner whose marker the store shows may move or remove it.
const shown = () => marker && store.marker?.owner === owner;
const present = (patch) => {
marker = { ...marker, ...patch };
store.publish(marker);
};
const clear = () => {
removeRender?.();
removeRender = null;
if (shown()) store.publish(null);
marker = null;
viewer?.scene?.requestRender?.();
};
return {
show(position) {
clear();
const scene = viewer?.scene;
if (
!position ||
!container?.ownerDocument ||
!scene?.postRender?.addEventListener ||
!scene.cartesianToCanvasCoordinates
)
return;
present({ owner, container, visible: false, left: 0, top: 0 });
const occluder = new cesium.EllipsoidalOccluder(
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
const render = () => {
if (!shown()) return;
occluder.cameraPosition = viewer.camera.positionWC;
const point = scene.cartesianToCanvasCoordinates(position);
const visible =
point &&
point.x >= 0 &&
point.y >= 0 &&
point.x <= scene.canvas.clientWidth &&
point.y <= scene.canvas.clientHeight &&
(scene.mode !== cesium.SceneMode.SCENE3D ||
occluder.isPointVisible(position));
if (!visible) {
// A hidden marker keeps its last place, as the element did.
if (marker.visible) present({ visible: false });
return;
}
const canvasRect = scene.canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const left = point.x + canvasRect.left - containerRect.left;
const top = point.y + canvasRect.top - containerRect.top;
if (marker.visible && marker.left === left && marker.top === top)
return;
present({ visible: true, left, top });
};
removeRender = scene.postRender.addEventListener(render);
render();
scene.requestRender?.();
},
clear,
destroy: clear,
};
}
+137
View File
@@ -0,0 +1,137 @@
// src/layers/wind/inspectionMarker.test.mjs — the wind inspection marker
// follows only its captured point, renders in its container, and releases
// its frame listener.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue';
import { createWindInspectionMarker } from './inspectionMarker.js';
const cesium = {
SceneMode: { SCENE3D: 3 },
Ellipsoid: { WGS84: {} },
EllipsoidalOccluder: class {
isPointVisible() {
return cesium.visible;
}
},
visible: true,
};
function fixture(t) {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
container.getBoundingClientRect = () => ({ left: 5, top: 10 });
const pinia = createPinia();
const app = createApp(WindInspectionMarker).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const frames = { listener: null };
const projected = [];
const viewer = {
camera: { positionWC: {} },
scene: {
mode: 3,
canvas: {
clientWidth: 800,
clientHeight: 600,
getBoundingClientRect: () => ({ left: 15, top: 30 }),
},
postRender: {
addEventListener(fn) {
assert.equal(frames.listener, null);
frames.listener = fn;
return () => {
frames.listener = null;
};
},
},
cartesianToCanvasCoordinates(point) {
projected.push(point);
return { x: 100, y: 200 };
},
requestRender() {},
},
};
const store = useWindInspectionStore(pinia);
cesium.visible = true;
const owner = createWindInspectionMarker({
container,
viewer,
cesium,
store,
});
const nodes = () => [
...container.querySelectorAll('.gev-wind-inspection-marker'),
];
return { owner, viewer, store, frames, projected, nodes, container, cesium };
}
test('the marker follows only its captured point, hides beyond the limb, and releases its listener', async (t) => {
const { owner, viewer, frames, projected, nodes } = fixture(t);
const point = { x: 1, y: 2, z: 3 };
owner.show(point);
await nextTick();
assert.equal(nodes().length, 1);
const [marker] = nodes();
assert.equal(marker.getAttribute('aria-hidden'), 'true');
assert.equal(marker.style.pointerEvents, 'none');
assert.equal(marker.style.position, 'absolute');
assert.equal(marker.style.zIndex, '150');
assert.equal(marker.style.left, '110px');
assert.equal(marker.style.top, '220px');
assert.equal(marker.hidden, false);
viewer.camera.positionWC = { moved: true };
cesium.visible = false;
frames.listener();
await nextTick();
assert.equal(marker.hidden, true);
assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place');
assert.ok(projected.every((value) => value === point));
cesium.visible = true;
frames.listener();
await nextTick();
assert.equal(marker.hidden, false);
owner.show({ x: 4 });
await nextTick();
assert.equal(nodes().length, 1);
owner.clear();
await nextTick();
assert.equal(nodes().length, 0);
assert.equal(frames.listener, null);
owner.show(point);
owner.destroy();
owner.destroy();
await nextTick();
assert.equal(nodes().length, 0);
assert.equal(frames.listener, null);
});
test('an unchanged frame publishes nothing, and an older owner leaves a newer marker', (t) => {
const { owner, viewer, store, frames, container } = fixture(t);
owner.show({ x: 1 });
let publishes = 0;
const publish = store.publish;
store.publish = (next) => {
publishes += 1;
publish(next);
};
frames.listener();
assert.equal(publishes, 0);
frames.listener = null;
const newer = createWindInspectionMarker({
container,
viewer,
cesium,
store,
});
newer.show({ x: 2 });
const shown = store.marker;
owner.destroy();
assert.ok(store.marker === shown);
newer.destroy();
assert.equal(store.marker, null);
});