mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge branch 'main' into fix/e-route-distance-ten-km
This commit is contained in:
commit
941a7a392e
779 files changed
+69864
-21453
No files matched your search
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlay } from './overlay.js';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
import {
|
||||
directionWedgePositions,
|
||||
alprLabelDetails,
|
||||
@@ -218,6 +219,23 @@ test('adapter labels never claim a custom source is public OSM data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a mapped bearing that rounds up to a whole turn reads 0°, not 360°', () => {
|
||||
const direction = (tag) =>
|
||||
alprLabelDetails(
|
||||
normalizeAlprNode({
|
||||
type: 'node',
|
||||
id: 1,
|
||||
lat: 30,
|
||||
lon: -97,
|
||||
tags: { 'surveillance:type': 'ALPR', 'camera:direction': tag },
|
||||
}),
|
||||
{ label: 'Custom directory' },
|
||||
).find((line) => line.startsWith('DIRECTION'));
|
||||
assert.equal(direction('359.6'), 'DIRECTION 0°');
|
||||
assert.equal(direction('359.4'), 'DIRECTION 359°');
|
||||
assert.equal(direction('0.4'), 'DIRECTION 0°');
|
||||
});
|
||||
|
||||
test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.equal(markerScale(100), 1.15);
|
||||
assert.equal(markerScale(40_000), 0.45);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DIRECTION_CONE_HALF_ANGLE_DEG,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
|
||||
@@ -50,7 +51,7 @@ export function alprLabelDetails(record, source) {
|
||||
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
|
||||
];
|
||||
if (Number.isFinite(record.directionDeg))
|
||||
details.push(`DIRECTION ${Math.round(record.directionDeg)}°`);
|
||||
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
|
||||
const equipment = [
|
||||
...new Set(
|
||||
[record.manufacturer, record.operator, record.cameraType]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// src/layers/awareness/directionOverlay.test.mjs — the Global Context compass
|
||||
// and contact bearings are presented to subscribers and rendered from a store
|
||||
// inside the viewer's container.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useAwarenessDirectionStore } from '../../ui/stores/awarenessDirection.js';
|
||||
import AwarenessDirection from '../../ui/views/panels/AwarenessDirection.vue';
|
||||
import { createState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
|
||||
/** The rendering part over a subject with one military contact due east. */
|
||||
function overlay(screenAngle) {
|
||||
const state = createState({ services: {} });
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
const subject = Cesium.Cartesian3.fromDegrees(0, 0, 1000);
|
||||
Object.assign(state, {
|
||||
viewer: {
|
||||
container,
|
||||
scene: { canvas: { clientWidth: 800, clientHeight: 600 } },
|
||||
camera: {
|
||||
heading: 0,
|
||||
rightWC: new Cesium.Cartesian3(0, 1, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
subject: { position: subject, layerId: 'military' },
|
||||
results: {
|
||||
cohorts: [
|
||||
{
|
||||
id: 'military',
|
||||
summary: {
|
||||
nearest: [
|
||||
{
|
||||
callsign: 'RCH123',
|
||||
position: Cesium.Cartesian3.fromDegrees(0.05, 0, 1000),
|
||||
distanceM: 5500,
|
||||
track: 90,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cohortPages: new Map(),
|
||||
});
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: {
|
||||
geometry: {
|
||||
getKeyholeGeometry: () => ({ centerX: 400, centerY: 300, radius: 250 }),
|
||||
celestialScreenAngle:
|
||||
screenAngle ||
|
||||
((x, y) => ({
|
||||
angle: Math.atan2(-y, x),
|
||||
opacity: 1,
|
||||
stable: true,
|
||||
})),
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
},
|
||||
parts: {},
|
||||
});
|
||||
return { state, container, rendering };
|
||||
}
|
||||
|
||||
test('the overlay publishes the compass and the nearest contact bearings', () => {
|
||||
const { rendering, container } = overlay();
|
||||
const views = [];
|
||||
rendering.subscribeDirectionOverlay((view) => views.push(view));
|
||||
assert.deepEqual(views, [null]);
|
||||
rendering.updateDirectionOverlay();
|
||||
const view = views.at(-1);
|
||||
assert.ok(view.container === container);
|
||||
assert.equal(view.hidden, false);
|
||||
// The compass sits at 66% of the 250px keyhole: 165px.
|
||||
assert.equal(view.ring.width, '330px');
|
||||
assert.equal(view.ring['--compass-rotation'], '0deg');
|
||||
assert.equal(view.heading.text, 'HDG 000°');
|
||||
assert.deepEqual(
|
||||
view.labels.map((label) => label.label),
|
||||
['N', 'E', 'S', 'W'],
|
||||
);
|
||||
assert.equal(view.labels[0].left, '400px');
|
||||
const [marker, second] = view.markers;
|
||||
assert.equal(marker.hidden, false);
|
||||
assert.match(marker.caption.text, /^RCH123 · .+\nBRG 090° · CRS 090°$/);
|
||||
assert.equal(marker.arrow.opacity, '1');
|
||||
assert.equal(second.hidden, true);
|
||||
// An unchanged frame publishes nothing.
|
||||
const count = views.length;
|
||||
rendering.updateDirectionOverlay();
|
||||
assert.equal(views.length, count);
|
||||
rendering.hideDirectionOverlay();
|
||||
assert.equal(views.at(-1).hidden, true);
|
||||
assert.equal(views.at(-1).heading.text, 'HDG 000°', 'placement is kept');
|
||||
rendering.removeDirectionOverlay();
|
||||
assert.equal(views.at(-1), null);
|
||||
});
|
||||
|
||||
test('an unstable frame keeps the last stable bearing, until the overlay is removed', () => {
|
||||
const answers = [
|
||||
{ angle: 1, opacity: 1, stable: true },
|
||||
{ angle: 2, opacity: 0.5, stable: false },
|
||||
];
|
||||
const previous = [];
|
||||
const { state, rendering } = overlay((x, y, last) => {
|
||||
previous.push(last);
|
||||
return answers.shift() ?? { angle: last ?? 0, opacity: 1, stable: true };
|
||||
});
|
||||
rendering.updateDirectionOverlay();
|
||||
assert.equal(state.directionAngles[0], 1);
|
||||
// The heading turns, so the next frame recomputes the contact.
|
||||
state.viewer.camera.heading = 0.1;
|
||||
rendering.updateDirectionOverlay();
|
||||
assert.equal(previous[1], 1, 'the unstable frame is given the last angle');
|
||||
assert.equal(state.directionAngles[0], 1, 'and does not replace it');
|
||||
rendering.removeDirectionOverlay();
|
||||
rendering.updateDirectionOverlay();
|
||||
assert.equal(previous[2], 0, 'a rebuilt overlay starts afresh');
|
||||
});
|
||||
|
||||
test('the scene chrome view renders the published overlay in its container', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useAwarenessDirectionStore(pinia);
|
||||
const app = createApp(AwarenessDirection).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const { rendering, container } = overlay();
|
||||
rendering.subscribeDirectionOverlay((view) => store.publish(view));
|
||||
rendering.ensureDirectionOverlay();
|
||||
await nextTick();
|
||||
const root = container.querySelector('#military-awareness-direction-overlay');
|
||||
assert.equal(root.hidden, true);
|
||||
assert.equal(root.getAttribute('aria-hidden'), 'true');
|
||||
assert.deepEqual(
|
||||
[...root.children].map((child) => child.className),
|
||||
[
|
||||
'military-awareness-compass-label cardinal-n',
|
||||
'military-awareness-compass-label cardinal-e',
|
||||
'military-awareness-compass-label cardinal-s',
|
||||
'military-awareness-compass-label cardinal-w',
|
||||
'military-awareness-compass-ring',
|
||||
'military-awareness-compass-heading',
|
||||
'military-awareness-direction-marker',
|
||||
'military-awareness-direction-marker',
|
||||
'military-awareness-direction-marker',
|
||||
],
|
||||
);
|
||||
assert.equal(root.children[0].dataset.bearing, '0');
|
||||
assert.equal(root.children[0].getAttribute('style'), null);
|
||||
rendering.updateDirectionOverlay();
|
||||
await nextTick();
|
||||
assert.equal(root.hidden, false);
|
||||
const ring = root.querySelector('.military-awareness-compass-ring');
|
||||
assert.equal(ring.style.getPropertyValue('--compass-rotation'), '0deg');
|
||||
assert.equal(
|
||||
root.querySelector('.military-awareness-compass-heading').textContent,
|
||||
'HDG 000°',
|
||||
);
|
||||
const [marker, second] = root.querySelectorAll(
|
||||
'.military-awareness-direction-marker',
|
||||
);
|
||||
assert.equal(marker.hidden, false);
|
||||
assert.equal(
|
||||
marker.querySelector('.military-awareness-direction-arrow').textContent,
|
||||
'➜',
|
||||
);
|
||||
assert.match(
|
||||
marker.querySelector('.military-awareness-direction-label').textContent,
|
||||
/^RCH123 · /,
|
||||
);
|
||||
assert.equal(second.hidden, true);
|
||||
rendering.removeDirectionOverlay();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
container.querySelector('#military-awareness-direction-overlay'),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -51,12 +51,13 @@ export function createAwarenessLayer({ services, source }) {
|
||||
parts.queries.collectAircraftProximityWindow,
|
||||
_getAwarenessNavigationStateForTest:
|
||||
parts.testing._getAwarenessNavigationStateForTest,
|
||||
_getAwarenessPanelRendersForTest:
|
||||
parts.testing._getAwarenessPanelRendersForTest,
|
||||
canNavigateAwarenessNext: parts.navigation.canNavigateAwarenessNext,
|
||||
historySubjectSnapshot: parts.history.historySubjectSnapshot,
|
||||
findCompatibleHistoryIndex: parts.history.findCompatibleHistoryIndex,
|
||||
awarenessPanelControlKey: parts.panel.awarenessPanelControlKey,
|
||||
captureAwarenessPanelFocus: parts.panel.captureAwarenessPanelFocus,
|
||||
restoreAwarenessPanelFocus: parts.panel.restoreAwarenessPanelFocus,
|
||||
subscribePanel: parts.panel.subscribePanel,
|
||||
subscribeDirectionOverlay: parts.rendering.subscribeDirectionOverlay,
|
||||
awarenessResultsAreLive: parts.panel.awarenessResultsAreLive,
|
||||
awarenessNeedsContinuousRender:
|
||||
parts.model.awarenessNeedsContinuousRender,
|
||||
|
||||
@@ -229,22 +229,8 @@ export function createLifecycle({
|
||||
destroy() {
|
||||
this.disable();
|
||||
detachRuntimeListeners();
|
||||
layerState.panel?.removeEventListener(
|
||||
'click',
|
||||
layerState.panelClickListener,
|
||||
);
|
||||
layerState.panelClickListener = null;
|
||||
if (layerState.panelOwned) layerState.panel?.remove();
|
||||
else if (layerState.panel) layerState.panel.replaceChildren();
|
||||
layerState.panel = null;
|
||||
layerState.panelOwned = false;
|
||||
layerState.directionRoot?.remove();
|
||||
layerState.directionRoot = null;
|
||||
layerState.compassRing = null;
|
||||
layerState.compassLabels = [];
|
||||
layerState.compassHeading = null;
|
||||
layerState.directionMarkers = [];
|
||||
layerState.panelMarkup = '';
|
||||
parts.panel.clearPanel();
|
||||
parts.rendering.removeDirectionOverlay();
|
||||
layerState.subjectListener = null;
|
||||
layerState.contextListener = null;
|
||||
layerState.clearListener = null;
|
||||
|
||||
+126
-157
@@ -2,7 +2,12 @@ import {
|
||||
formatAwarenessLabel,
|
||||
AWARENESS_RADIUS_M,
|
||||
} from '../../data/militaryAwarenessEngine.js';
|
||||
import { formatNumber, t, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
formatNumber,
|
||||
onLocaleChange,
|
||||
t,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { AWARENESS_PAGE_SIZE, AWARENESS_PAGE_ROTATE_MS } from './policy.js';
|
||||
|
||||
@@ -43,76 +48,95 @@ function presentAwarenessReason(summary = {}) {
|
||||
: translateKnown(summary.reason || '');
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* The Contacts panel as a view renders it. The layer presents a plain view
|
||||
* object (`standby`, `results` or `empty`) and publishes it to subscribers;
|
||||
* Core's AwarenessPanel component renders it and calls the layer's
|
||||
* navigation methods. Text arrives in the UI language.
|
||||
*/
|
||||
export function createPanel({ state: layerState, parts }) {
|
||||
/** Publish a fresh view unless it is the one already shown. */
|
||||
function publish(view) {
|
||||
const signature = JSON.stringify(view);
|
||||
if (layerState.panelSignature === signature) return;
|
||||
layerState.panelSignature = signature;
|
||||
layerState.panelView = view;
|
||||
for (const listener of [...layerState.panelListeners]) listener(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive every published view, starting with the current one.
|
||||
* @param {(view: object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribePanel(listener) {
|
||||
layerState.panelListeners.add(listener);
|
||||
if (layerState.panelView) listener(layerState.panelView);
|
||||
return () => layerState.panelListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The panel shows layer views from the first enable or result onwards. */
|
||||
function ensurePanel() {
|
||||
if (layerState.panel) return layerState.panel;
|
||||
const existing = document.getElementById('military-awareness-panel');
|
||||
const panel = existing || document.createElement('aside');
|
||||
layerState.panelOwned = !existing;
|
||||
if (!existing) {
|
||||
panel.id = 'military-awareness-panel';
|
||||
}
|
||||
layerState.panelClickListener = (event) => {
|
||||
const action = event.target.closest('button[data-awareness-action]');
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
if (action.dataset.awarenessAction === 'previous')
|
||||
parts.history.navigateHistory(-1, { origin: 'user' });
|
||||
if (action.dataset.awarenessAction === 'next')
|
||||
parts.history.navigateHistory(1, { origin: 'user' });
|
||||
if (action.dataset.awarenessAction === 'focus')
|
||||
parts.focus.focusCurrentSubject({ origin: 'user' });
|
||||
return;
|
||||
}
|
||||
const target = event.target.closest(
|
||||
'button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
parts.focus.requestFocus(
|
||||
target.dataset.awarenessLayer,
|
||||
target.dataset.awarenessId,
|
||||
false,
|
||||
{ origin: 'user' },
|
||||
);
|
||||
};
|
||||
panel.addEventListener('click', layerState.panelClickListener);
|
||||
if (!existing) document.body.appendChild(panel);
|
||||
layerState.panel = panel;
|
||||
return panel;
|
||||
layerState.panelShown = true;
|
||||
// A shown view is worded again in a new locale.
|
||||
layerState.offPanelLocale ??= onLocaleChange(() => {
|
||||
const kind = layerState.panelView?.kind;
|
||||
if (kind === 'results') renderResults();
|
||||
else if (kind === 'standby') hidePanel();
|
||||
});
|
||||
}
|
||||
|
||||
function hidePanel() {
|
||||
if (layerState.panel) {
|
||||
const markup = `<div class="military-awareness-standby">
|
||||
<strong>${layerState.enabled ? t('CONTEXT READY') : t('GLOBAL CONTEXT OFF')}</strong>
|
||||
<span>${layerState.enabled ? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION') : t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY')}</span>
|
||||
</div>`;
|
||||
layerState.panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
layerState.panel.innerHTML = markup;
|
||||
layerState.panelMarkup = markup;
|
||||
}
|
||||
}
|
||||
if (layerState.directionRoot) layerState.directionRoot.hidden = true;
|
||||
if (layerState.panelShown)
|
||||
publish({
|
||||
kind: 'standby',
|
||||
title: layerState.enabled
|
||||
? t('CONTEXT READY')
|
||||
: t('GLOBAL CONTEXT OFF'),
|
||||
text: layerState.enabled
|
||||
? t('SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION')
|
||||
: t('ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY'),
|
||||
});
|
||||
parts.rendering.hideDirectionOverlay();
|
||||
}
|
||||
|
||||
function rowHtml(cohort) {
|
||||
/** Clear the panel when the layer is destroyed. */
|
||||
function clearPanel() {
|
||||
if (layerState.panelShown) publish({ kind: 'empty' });
|
||||
layerState.offPanelLocale?.();
|
||||
layerState.offPanelLocale = null;
|
||||
layerState.panelShown = false;
|
||||
layerState.panelView = null;
|
||||
layerState.panelSignature = '';
|
||||
}
|
||||
|
||||
function presentRow(cohort) {
|
||||
const summary = cohort.summary;
|
||||
const count = presentAwarenessCount(summary);
|
||||
const page = layerState.cohortPages.get(cohort.id) || 0;
|
||||
const nearest = summary.nearest
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
.map((item) => {
|
||||
.map((item, index) => {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
if (!targetId) {
|
||||
return `<li><span class="military-awareness-target unavailable" aria-label="${t('Unavailable')}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></span></li>`;
|
||||
}
|
||||
const distance = presentAwarenessDistance(item.distanceM);
|
||||
if (!targetId)
|
||||
return {
|
||||
key: `unavailable:${page + index}`,
|
||||
label,
|
||||
distance,
|
||||
ariaLabel: t('Unavailable'),
|
||||
};
|
||||
const accessibleLabel = label === '—' ? t('Unavailable') : label;
|
||||
return `<li><button type="button" class="military-awareness-target" data-awareness-layer="${escapeHtml(cohort.id)}" data-awareness-id="${escapeHtml(targetId)}" aria-label="${escapeHtml(t('Focus {label}', { label: accessibleLabel }))}">${escapeHtml(label)} <span>${presentAwarenessDistance(item.distanceM)}</span></button>${namedAreasHtml(item.memberNames)}</li>`;
|
||||
})
|
||||
.join('');
|
||||
return {
|
||||
key: `target:${cohort.id}:${targetId}`,
|
||||
layer: cohort.id,
|
||||
targetId: String(targetId),
|
||||
label,
|
||||
distance,
|
||||
ariaLabel: t('Focus {label}', { label: accessibleLabel }),
|
||||
names: presentNames(item.memberNames),
|
||||
};
|
||||
});
|
||||
const pageCount = Math.max(
|
||||
1,
|
||||
Math.ceil(summary.nearest.length / AWARENESS_PAGE_SIZE),
|
||||
@@ -121,109 +145,58 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
pageCount > 1
|
||||
? ` · ${Math.floor(page / AWARENESS_PAGE_SIZE) + 1}/${pageCount}`
|
||||
: '';
|
||||
const coverage = cohort.coverage ? ` · ${escapeHtml(cohort.coverage)}` : '';
|
||||
return `<section class="military-awareness-row ${summary.relationship.toLowerCase()}">
|
||||
<div><strong>${escapeHtml(cohort.label)}</strong><b aria-live="polite">${count}${pageLabel}</b></div>
|
||||
<small>${escapeHtml(cohort.source)}${coverage} · ${escapeHtml(presentAwarenessReason(summary))}</small>
|
||||
${nearest ? `<ul>${nearest}</ul>` : ''}
|
||||
</section>`;
|
||||
const coverage = cohort.coverage ? ` · ${cohort.coverage}` : '';
|
||||
return {
|
||||
id: cohort.id,
|
||||
relationship: summary.relationship.toLowerCase(),
|
||||
label: cohort.label,
|
||||
count: `${presentAwarenessCount(summary)}${pageLabel}`,
|
||||
detail: `${cohort.source}${coverage} · ${presentAwarenessReason(summary)}`,
|
||||
nearest,
|
||||
};
|
||||
}
|
||||
|
||||
function navigationControlsHtml() {
|
||||
const canPrevious = layerState.navigationIndex > 0;
|
||||
return `<div class="military-awareness-controls" role="group" aria-label="${t('Global Context navigation')}">
|
||||
<button type="button" data-awareness-action="previous" title="${t('Previous — prior visited contact in the 250 km window')}"${canPrevious ? '' : ' disabled'}>${t('PREVIOUS')}</button>
|
||||
<button type="button" data-awareness-action="focus">${t('FOCUS')}</button>
|
||||
<button type="button" data-awareness-action="next" title="${t('Next — nearest unvisited contact in the 250 km window')}"${parts.navigation.canNavigateNext() ? '' : ' disabled'}>${t('NEXT')}</button>
|
||||
</div>`;
|
||||
function presentNavigation() {
|
||||
return {
|
||||
label: t('Global Context navigation'),
|
||||
previous: {
|
||||
text: t('PREVIOUS'),
|
||||
title: t('Previous — prior visited contact in the 250 km window'),
|
||||
disabled: !(layerState.navigationIndex > 0),
|
||||
},
|
||||
focus: { text: t('FOCUS') },
|
||||
next: {
|
||||
text: t('NEXT'),
|
||||
title: t('Next — nearest unvisited contact in the 250 km window'),
|
||||
disabled: !parts.navigation.canNavigateNext(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable identity for a delegated Contacts-panel button across live repaints. */
|
||||
|
||||
function awarenessPanelControlKey(element) {
|
||||
const control = element?.closest?.(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
);
|
||||
if (!control) return null;
|
||||
if (control.dataset.awarenessAction)
|
||||
return `action:${control.dataset.awarenessAction}`;
|
||||
return `target:${control.dataset.awarenessLayer}:${control.dataset.awarenessId}`;
|
||||
}
|
||||
|
||||
/** Capture stable identity so a live repaint can restore only the same control. */
|
||||
|
||||
function captureAwarenessPanelFocus(
|
||||
panel,
|
||||
activeElement = document.activeElement,
|
||||
) {
|
||||
if (!panel?.contains?.(activeElement)) return null;
|
||||
if (activeElement?.matches?.('[data-awareness-focus-continuation]')) {
|
||||
return { key: 'continuation' };
|
||||
}
|
||||
const key = awarenessPanelControlKey(activeElement);
|
||||
if (!key) return null;
|
||||
return { key };
|
||||
}
|
||||
|
||||
/** Restore the same control, or continue beyond the list when it is no longer rendered. */
|
||||
|
||||
function restoreAwarenessPanelFocus(panel, snapshot) {
|
||||
if (!panel || !snapshot) return null;
|
||||
const continuation = panel.querySelector(
|
||||
'[data-awareness-focus-continuation]',
|
||||
);
|
||||
const controls = [
|
||||
...panel.querySelectorAll(
|
||||
'button[data-awareness-action], button[data-awareness-layer][data-awareness-id]',
|
||||
),
|
||||
].filter((control) => !control.disabled);
|
||||
const retained =
|
||||
snapshot.key === 'continuation'
|
||||
? continuation
|
||||
: controls.find(
|
||||
(control) => awarenessPanelControlKey(control) === snapshot.key,
|
||||
);
|
||||
const target = retained || continuation;
|
||||
target?.focus?.({ preventScroll: true });
|
||||
return target || null;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>'"]/g,
|
||||
(char) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
"'": ''',
|
||||
'"': '"',
|
||||
})[char],
|
||||
);
|
||||
}
|
||||
|
||||
function namedAreasHtml(names) {
|
||||
function presentNames(names) {
|
||||
return names?.length
|
||||
? `<details class="military-awareness-names"><summary>${escapeHtml(t('Named areas ({n})', { n: formatNumber(names.length) }))}</summary><div>${names.map((name) => escapeHtml(name)).join('<br>')}</div></details>`
|
||||
: '';
|
||||
? {
|
||||
summary: t('Named areas ({n})', { n: formatNumber(names.length) }),
|
||||
names: [...names].map((name) => String(name ?? '')),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function renderResults() {
|
||||
if (!layerState.enabled || !layerState.results) return hidePanel();
|
||||
const panel = ensurePanel();
|
||||
ensurePanel();
|
||||
layerState.panelRenders += 1;
|
||||
const { subject, cohorts } = layerState.results;
|
||||
const markup = `<div class="military-awareness-subject">${escapeHtml(subject.label)} · ${escapeHtml(t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) }))}</div>
|
||||
${namedAreasHtml(subject.memberNames)}
|
||||
${navigationControlsHtml()}
|
||||
${cohorts.map(rowHtml).join('')}
|
||||
<p class="military-awareness-note" tabindex="-1" data-awareness-focus-continuation>${t('Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.')}</p>`;
|
||||
panel.hidden = false;
|
||||
if (layerState.panelMarkup !== markup) {
|
||||
const focusSnapshot = captureAwarenessPanelFocus(panel);
|
||||
panel.innerHTML = markup;
|
||||
layerState.panelMarkup = markup;
|
||||
restoreAwarenessPanelFocus(panel, focusSnapshot);
|
||||
}
|
||||
publish({
|
||||
kind: 'results',
|
||||
subject: `${subject.label} · ${t('{distance} FLIGHT / VESSEL WINDOW', { distance: presentAwarenessDistance(AWARENESS_RADIUS_M) })}`,
|
||||
subjectNames: presentNames(subject.memberNames),
|
||||
navigation: presentNavigation(),
|
||||
rows: cohorts.map(presentRow),
|
||||
note: t(
|
||||
'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function rotateAwarenessPages() {
|
||||
@@ -277,14 +250,10 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
return cohorts.some((cohort) => Number(cohort?.summary?.count) > 0);
|
||||
}
|
||||
return {
|
||||
subscribePanel,
|
||||
ensurePanel,
|
||||
hidePanel,
|
||||
rowHtml,
|
||||
navigationControlsHtml,
|
||||
awarenessPanelControlKey,
|
||||
captureAwarenessPanelFocus,
|
||||
restoreAwarenessPanelFocus,
|
||||
escapeHtml,
|
||||
clearPanel,
|
||||
renderResults,
|
||||
rotateAwarenessPages,
|
||||
startAwarenessPageRotation,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* Format a compass direction for the awareness overlay.
|
||||
* @param {number} degrees
|
||||
@@ -5,6 +7,5 @@
|
||||
*/
|
||||
export function formatAwarenessDirection(degrees) {
|
||||
if (!Number.isFinite(degrees)) return '—';
|
||||
const normalized = ((degrees % 360) + 360) % 360;
|
||||
return String(Math.round(normalized) % 360).padStart(3, '0');
|
||||
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
|
||||
}
|
||||
@@ -22,55 +22,75 @@ export function createRendering({
|
||||
const { getKeyholeGeometry, celestialScreenAngle } = services.geometry;
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
const CARDINALS = [
|
||||
['N', 0],
|
||||
['E', 90],
|
||||
['S', 180],
|
||||
['W', 270],
|
||||
];
|
||||
|
||||
/**
|
||||
* The direction overlay as its view renders it: the compass ring, its
|
||||
* cardinals and heading, and up to three contact markers inside the
|
||||
* viewer's container. Each part keeps its last placement while hidden.
|
||||
*/
|
||||
function ensureDirectionOverlay() {
|
||||
if (layerState.directionRoot || !layerState.viewer)
|
||||
return layerState.directionRoot;
|
||||
const root = document.createElement('div');
|
||||
root.id = 'military-awareness-direction-overlay';
|
||||
root.hidden = true;
|
||||
root.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const compass = document.createElement('div');
|
||||
compass.className = 'military-awareness-compass-ring';
|
||||
const cardinals = [
|
||||
['N', 0],
|
||||
['E', 90],
|
||||
['S', 180],
|
||||
['W', 270],
|
||||
];
|
||||
layerState.compassLabels = cardinals.map(([label, bearing]) => {
|
||||
const element = document.createElement('span');
|
||||
element.className = `military-awareness-compass-label cardinal-${label.toLowerCase()}`;
|
||||
element.textContent = label;
|
||||
element.dataset.bearing = String(bearing);
|
||||
root.appendChild(element);
|
||||
return element;
|
||||
if (layerState.directionView || !layerState.viewer)
|
||||
return layerState.directionView;
|
||||
presentDirection({
|
||||
container: layerState.viewer.container,
|
||||
hidden: true,
|
||||
ring: null,
|
||||
heading: { text: '', left: null, top: null },
|
||||
labels: CARDINALS.map(([label, bearing]) => ({
|
||||
label,
|
||||
bearing,
|
||||
left: null,
|
||||
top: null,
|
||||
})),
|
||||
markers: Array.from({ length: 3 }, () => ({
|
||||
hidden: false,
|
||||
style: null,
|
||||
arrow: null,
|
||||
caption: { text: '', style: null },
|
||||
})),
|
||||
});
|
||||
const heading = document.createElement('span');
|
||||
heading.className = 'military-awareness-compass-heading';
|
||||
root.append(compass, heading);
|
||||
return layerState.directionView;
|
||||
}
|
||||
|
||||
layerState.directionMarkers = Array.from({ length: 3 }, () => {
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'military-awareness-direction-marker';
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'military-awareness-direction-arrow';
|
||||
arrow.textContent = '➜';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'military-awareness-direction-label';
|
||||
marker.append(arrow, label);
|
||||
marker._arrow = arrow;
|
||||
marker._label = label;
|
||||
marker._lastAngle = 0;
|
||||
root.appendChild(marker);
|
||||
return marker;
|
||||
});
|
||||
/** Publish a change to the overlay, skipping one that changes nothing. */
|
||||
function presentDirection(patch) {
|
||||
const next = patch && { ...layerState.directionView, ...patch };
|
||||
const signature = JSON.stringify(next, (name, value) =>
|
||||
name === 'container' ? undefined : value,
|
||||
);
|
||||
if (next && signature === layerState.directionSignature) return;
|
||||
layerState.directionSignature = next ? signature : '';
|
||||
layerState.directionView = next && Object.freeze(next);
|
||||
for (const listener of [...layerState.directionListeners])
|
||||
listener(layerState.directionView);
|
||||
}
|
||||
|
||||
layerState.compassRing = compass;
|
||||
layerState.compassHeading = heading;
|
||||
layerState.viewer.container.appendChild(root);
|
||||
layerState.directionRoot = root;
|
||||
return root;
|
||||
/**
|
||||
* Receive the direction overlay now and after every change.
|
||||
* @param {(view: ?object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribeDirectionOverlay(listener) {
|
||||
layerState.directionListeners.add(listener);
|
||||
listener(layerState.directionView);
|
||||
return () => layerState.directionListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Hide the overlay, keeping its last placement. */
|
||||
function hideDirectionOverlay() {
|
||||
if (layerState.directionView) presentDirection({ hidden: true });
|
||||
}
|
||||
|
||||
/** Withdraw the overlay when the layer is destroyed. */
|
||||
function removeDirectionOverlay() {
|
||||
if (layerState.directionView) presentDirection(null);
|
||||
layerState.directionAngles = [0, 0, 0];
|
||||
}
|
||||
|
||||
function scheduleDirectionOverlayUpdate(force = false) {
|
||||
@@ -104,7 +124,7 @@ export function createRendering({
|
||||
!layerState.subject?.position ||
|
||||
!layerState.results
|
||||
) {
|
||||
if (root) root.hidden = true;
|
||||
if (root) presentDirection({ hidden: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,41 +136,43 @@ export function createRendering({
|
||||
: null;
|
||||
const geometry = getKeyholeGeometry(width, height);
|
||||
if (!(geometry.radius > 0)) {
|
||||
root.hidden = true;
|
||||
presentDirection({ hidden: true });
|
||||
return;
|
||||
}
|
||||
|
||||
root.hidden = false;
|
||||
// Keep the compass comfortably inside the shader keyhole so its cardinals
|
||||
// remain visible around the subject instead of hiding under edge panels.
|
||||
const compassRadius = Math.max(
|
||||
120,
|
||||
Math.min(geometry.radius - 48, geometry.radius * 0.66),
|
||||
);
|
||||
layerState.compassRing.style.left = `${geometry.centerX - compassRadius}px`;
|
||||
layerState.compassRing.style.top = `${geometry.centerY - compassRadius}px`;
|
||||
layerState.compassRing.style.width = `${compassRadius * 2}px`;
|
||||
layerState.compassRing.style.height = `${compassRadius * 2}px`;
|
||||
|
||||
const cameraHeading = layerState.viewer.camera.heading || 0;
|
||||
const headingDeg =
|
||||
(Math.round(Cesium.Math.toDegrees(cameraHeading)) + 360) % 360;
|
||||
layerState.compassRing.style.setProperty(
|
||||
'--compass-rotation',
|
||||
`${-headingDeg}deg`,
|
||||
);
|
||||
layerState.compassHeading.textContent = t('HDG {value}°', {
|
||||
value: String(headingDeg).padStart(3, '0'),
|
||||
});
|
||||
layerState.compassHeading.style.left = `${geometry.centerX}px`;
|
||||
layerState.compassHeading.style.top = `${geometry.centerY - compassRadius + 39}px`;
|
||||
for (const label of layerState.compassLabels) {
|
||||
const bearing = Cesium.Math.toRadians(Number(label.dataset.bearing));
|
||||
const ring = {
|
||||
left: `${geometry.centerX - compassRadius}px`,
|
||||
top: `${geometry.centerY - compassRadius}px`,
|
||||
width: `${compassRadius * 2}px`,
|
||||
height: `${compassRadius * 2}px`,
|
||||
'--compass-rotation': `${-headingDeg}deg`,
|
||||
};
|
||||
const heading = {
|
||||
text: t('HDG {value}°', {
|
||||
value: String(headingDeg).padStart(3, '0'),
|
||||
}),
|
||||
left: `${geometry.centerX}px`,
|
||||
top: `${geometry.centerY - compassRadius + 39}px`,
|
||||
};
|
||||
const labels = root.labels.map((label) => {
|
||||
const bearing = Cesium.Math.toRadians(label.bearing);
|
||||
const angle = bearing - cameraHeading - Cesium.Math.PI_OVER_TWO;
|
||||
const labelRadius = compassRadius - 17;
|
||||
label.style.left = `${geometry.centerX + Math.cos(angle) * labelRadius}px`;
|
||||
label.style.top = `${geometry.centerY + Math.sin(angle) * labelRadius}px`;
|
||||
}
|
||||
return {
|
||||
...label,
|
||||
left: `${geometry.centerX + Math.cos(angle) * labelRadius}px`,
|
||||
top: `${geometry.centerY + Math.sin(angle) * labelRadius}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const directionalCohort =
|
||||
layerState.results.cohorts.find(
|
||||
@@ -163,8 +185,9 @@ export function createRendering({
|
||||
page,
|
||||
page + AWARENESS_PAGE_SIZE,
|
||||
) || [];
|
||||
for (let index = 0; index < layerState.directionMarkers.length; index++) {
|
||||
const marker = layerState.directionMarkers[index];
|
||||
const markers = root.markers.map((marker) => ({ ...marker }));
|
||||
for (let index = 0; index < markers.length; index++) {
|
||||
const marker = markers[index];
|
||||
const item = military[index];
|
||||
if (!item?.position) {
|
||||
marker.hidden = true;
|
||||
@@ -183,27 +206,32 @@ export function createRendering({
|
||||
const projection = celestialScreenAngle(
|
||||
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.rightWC),
|
||||
Cesium.Cartesian3.dot(direction, layerState.viewer.camera.upWC),
|
||||
marker._lastAngle,
|
||||
layerState.directionAngles[index],
|
||||
);
|
||||
if (projection.stable) marker._lastAngle = projection.angle;
|
||||
if (projection.stable)
|
||||
layerState.directionAngles[index] = projection.angle;
|
||||
// Every detected contact shares the compass rim. Keeping the bearing
|
||||
// markers on one radius makes them read as compass contacts rather than
|
||||
// unconstrained labels floating over the map.
|
||||
const markerRadius = compassRadius;
|
||||
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
|
||||
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
|
||||
marker.style.opacity = String(projection.opacity);
|
||||
marker.style = {
|
||||
left: `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`,
|
||||
top: `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`,
|
||||
opacity: String(projection.opacity),
|
||||
};
|
||||
const sonarFactor =
|
||||
sonar?.at(
|
||||
geometry.centerX + Math.cos(projection.angle) * markerRadius,
|
||||
geometry.centerY + Math.sin(projection.angle) * markerRadius,
|
||||
) ?? 1;
|
||||
marker._arrow.style.opacity = String(sonarFactor);
|
||||
marker._label.style.opacity = String(
|
||||
sonar ? sonar.label(sonarFactor) : 1,
|
||||
);
|
||||
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
|
||||
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
|
||||
marker.arrow = {
|
||||
opacity: String(sonarFactor),
|
||||
transform: `translate(-50%, -50%) rotate(${projection.angle}rad)`,
|
||||
};
|
||||
const labelStyle = {
|
||||
opacity: String(sonar ? sonar.label(sonarFactor) : 1),
|
||||
transform: `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`,
|
||||
};
|
||||
const label = formatAwarenessLabel(item);
|
||||
const subjectCartographic = Cesium.Cartographic.fromCartesian(
|
||||
layerState.subject.position,
|
||||
@@ -234,9 +262,13 @@ export function createRendering({
|
||||
value: formatAwarenessDirection(item.track),
|
||||
})}`
|
||||
: '';
|
||||
marker._label.textContent = `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`;
|
||||
marker.caption = {
|
||||
text: `${label} · ${formatAwarenessDistance(item.distanceM)}\n${bearingText}${courseText}`,
|
||||
style: labelStyle,
|
||||
};
|
||||
marker.hidden = false;
|
||||
}
|
||||
presentDirection({ hidden: false, ring, heading, labels, markers });
|
||||
}
|
||||
|
||||
function clearVisual() {
|
||||
@@ -303,6 +335,9 @@ export function createRendering({
|
||||
}
|
||||
return {
|
||||
ensureDirectionOverlay,
|
||||
subscribeDirectionOverlay,
|
||||
hideDirectionOverlay,
|
||||
removeDirectionOverlay,
|
||||
scheduleDirectionOverlayUpdate,
|
||||
cancelDirectionOverlayUpdate,
|
||||
updateDirectionOverlay,
|
||||
|
||||
@@ -29,20 +29,26 @@ export function createState({ services }) {
|
||||
subject: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
panel: null,
|
||||
panelOwned: false,
|
||||
/** Whether layer views replace the panel's initial markup yet. */
|
||||
panelShown: false,
|
||||
/** The published panel view, its signature and its subscribers. */
|
||||
panelView: null,
|
||||
panelSignature: '',
|
||||
panelListeners: new Set(),
|
||||
/** Result renders, published or not; tests observe repaint storms. */
|
||||
panelRenders: 0,
|
||||
subjectListener: null,
|
||||
contextListener: null,
|
||||
clearListener: null,
|
||||
subjectClearListener: null,
|
||||
runtimeListenersAttached: false,
|
||||
preRenderRemover: null,
|
||||
panelClickListener: null,
|
||||
directionRoot: null,
|
||||
compassRing: null,
|
||||
compassLabels: [],
|
||||
compassHeading: null,
|
||||
directionMarkers: [],
|
||||
/** The published direction overlay, its signature and its subscribers. */
|
||||
directionView: null,
|
||||
directionSignature: '',
|
||||
directionListeners: new Set(),
|
||||
/** Each marker's last stable screen angle. */
|
||||
directionAngles: [0, 0, 0],
|
||||
navigationHistory: [],
|
||||
navigationVisited: new Set(),
|
||||
navigationIndex: -1,
|
||||
@@ -57,7 +63,6 @@ export function createState({ services }) {
|
||||
cameraMoving: false,
|
||||
lastEvaluatedPosition: null,
|
||||
sourceRevision: '',
|
||||
panelMarkup: '',
|
||||
directionFrame: null,
|
||||
lastDirectionUpdateMs: 0,
|
||||
activationId: 0,
|
||||
|
||||
@@ -11,5 +11,12 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
pendingSelectionKey: layerState.pendingSelectionKey,
|
||||
};
|
||||
}
|
||||
return { _getAwarenessNavigationStateForTest };
|
||||
/** @returns {number} How many result renders ran, published or not. */
|
||||
function _getAwarenessPanelRendersForTest() {
|
||||
return layerState.panelRenders;
|
||||
}
|
||||
return {
|
||||
_getAwarenessNavigationStateForTest,
|
||||
_getAwarenessPanelRendersForTest,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Vendored
+8
-9
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
/**
|
||||
* @module headingConfidence
|
||||
@@ -44,7 +45,7 @@ export function isHeadingEstimated(camera) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function headingHudToken(camera) {
|
||||
const hdg = Math.round(Number(camera?.headingDeg) || 0);
|
||||
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
|
||||
return isHeadingEstimated(camera)
|
||||
? t('HDG {value}° (ESTIMATED)', { value: hdg })
|
||||
: t('HDG {value}°', { value: hdg });
|
||||
|
||||
@@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => {
|
||||
assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°');
|
||||
assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°');
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }),
|
||||
'HDG 0° (ESTIMATED)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
|
||||
assert.equal(
|
||||
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// The CCTV HUD summary shows the CAL badge, source kind and view band in the
|
||||
// UI language; English keeps its codes, and unknown source codes stay codes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
|
||||
function summary(sourceKind) {
|
||||
const camera = {
|
||||
id: 'c1',
|
||||
city: 'Austin',
|
||||
name: 'Cam',
|
||||
rangeM: 100,
|
||||
fovDeg: 60,
|
||||
feedType: 'image',
|
||||
};
|
||||
const record = { camera };
|
||||
const presentation = createPresentation({
|
||||
state: {
|
||||
_records: [record],
|
||||
_healthById: new Map(sourceKind ? [['c1', { sourceKind }]] : []),
|
||||
_showProjection: false,
|
||||
_coverageMode: 'sector',
|
||||
},
|
||||
services: {},
|
||||
parts: {
|
||||
selection: { getActiveRecord: () => record },
|
||||
model: {
|
||||
sectorAreaKm2: () => 1,
|
||||
currentViewContext: () => 'street:1:2',
|
||||
},
|
||||
geometry: { coverageNeighborCount: () => 0 },
|
||||
calibration: { deriveCalBadge: () => 'raw-prior' },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
return presentation.buildSummaryText();
|
||||
}
|
||||
|
||||
test('en: CAL, SRC and CONTEXT keep their English codes', () => {
|
||||
setLocale('en');
|
||||
assert.match(summary('upstream'), /CAL RAW PRIOR · SRC UPSTREAM/);
|
||||
assert.match(summary(null), /SRC IMAGE · STREET CONTEXT/);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
});
|
||||
|
||||
test('fr: CAL, SRC and CONTEXT values are translated', () => {
|
||||
setLocale('fr');
|
||||
try {
|
||||
assert.match(
|
||||
summary('upstream'),
|
||||
/CAL ESTIM\. INITIALE · SRC AMONT · CONTEXTE RUE/,
|
||||
);
|
||||
assert.match(summary('upstream-image'), /SRC UPSTREAM-IMAGE/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
+105
-14
@@ -1,5 +1,5 @@
|
||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { isolate, msg, t } from '../../i18n/index.js';
|
||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||
import {
|
||||
@@ -8,6 +8,59 @@ import {
|
||||
isDatasheetFov,
|
||||
} from './modelSpecs.js';
|
||||
|
||||
// The summary line names the CAL badge, the source kind and the view band in
|
||||
// the page language; the codes underneath stay English.
|
||||
const CAL_BADGE_LABELS = Object.freeze({
|
||||
calibrated: msg('CALIBRATED'),
|
||||
curated: msg('CURATED'),
|
||||
'raw-prior': msg('RAW PRIOR'),
|
||||
});
|
||||
|
||||
// The same source-kind words as the CCTV panel (src/ui/cctvFrames.js).
|
||||
const SOURCE_KIND_LABELS = Object.freeze({
|
||||
snapshot: msg('SNAPSHOT'),
|
||||
live: msg('LIVE'),
|
||||
fallback: msg('FALLBACK'),
|
||||
upstream: msg('UPSTREAM'),
|
||||
synthetic: msg('SYNTHETIC'),
|
||||
configured: msg('CONFIGURED'),
|
||||
seed: msg('SEED'),
|
||||
image: msg('IMAGE'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
/** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */
|
||||
export function summaryCalLabel(badge) {
|
||||
const key = String(badge || '');
|
||||
return CAL_BADGE_LABELS[key]
|
||||
? t(CAL_BADGE_LABELS[key])
|
||||
: key.replace('-', ' ').toUpperCase();
|
||||
}
|
||||
|
||||
/** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */
|
||||
export function summarySourceLabel(kind) {
|
||||
const key = String(kind || 'image').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** View band id ('street', 'city'…) as the HUD names it. */
|
||||
export function summaryViewBandLabel(band) {
|
||||
switch (band) {
|
||||
case 'street':
|
||||
return t('STREET', { context: 'view band' });
|
||||
case 'city':
|
||||
return t('CITY', { context: 'view band' });
|
||||
case 'regional':
|
||||
return t('REGIONAL', { context: 'view band' });
|
||||
case 'global':
|
||||
return t('GLOBAL', { context: 'view band' });
|
||||
default:
|
||||
return String(band || '').toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -44,6 +97,8 @@ export function createPresentation({
|
||||
const health = layerState._healthById.get(active.camera.id) || null;
|
||||
const calBadge = parts.calibration.deriveCalBadge(active.camera);
|
||||
|
||||
// Each part is isolated: in right-to-left text a code keeps its own value
|
||||
// (FOV 44°) instead of trading it with its neighbour.
|
||||
return [
|
||||
t('{city} CCTV', {
|
||||
city: active.camera.city.toUpperCase(),
|
||||
@@ -72,17 +127,13 @@ export function createPresentation({
|
||||
: t('ISOLATED VIEW'),
|
||||
layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'),
|
||||
layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null,
|
||||
t('CAL {value}', {
|
||||
value: calBadge.replace('-', ' ').toUpperCase(),
|
||||
t('CAL {value}', { value: summaryCalLabel(calBadge) }),
|
||||
t('SRC {value}', {
|
||||
value: summarySourceLabel(
|
||||
health?.sourceKind || active.camera.feedType || 'image',
|
||||
),
|
||||
}),
|
||||
health?.sourceKind
|
||||
? t('SRC {value}', {
|
||||
value: String(health.sourceKind).toUpperCase(),
|
||||
})
|
||||
: t('SRC {value}', {
|
||||
value: String(active.camera.feedType || 'image').toUpperCase(),
|
||||
}),
|
||||
t('{value} CONTEXT', { value: viewBand.toUpperCase() }),
|
||||
t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }),
|
||||
// Identified hardware: append the model's datasheet specs + attribution.
|
||||
active.camera.spec
|
||||
? t('{spec} · SPECS: CCTV-DATABASE.COM', {
|
||||
@@ -91,6 +142,7 @@ export function createPresentation({
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map(isolate)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
@@ -225,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() {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// The CCTV summary line names the CAL badge, the source kind and the view
|
||||
// band in the page language; unknown codes stay as uppercase codes.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import {
|
||||
summaryCalLabel,
|
||||
summarySourceLabel,
|
||||
summaryViewBandLabel,
|
||||
} from './presentation.js';
|
||||
|
||||
function inLocale(locale, run) {
|
||||
setLocale(locale);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
}
|
||||
|
||||
test('English keeps the summary codes', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'RAW PRIOR');
|
||||
assert.equal(summarySourceLabel('image'), 'IMAGE');
|
||||
assert.equal(summarySourceLabel('hls'), 'HLS');
|
||||
assert.equal(summaryViewBandLabel('street'), 'STREET');
|
||||
assert.equal(summaryViewBandLabel('none'), 'NONE');
|
||||
});
|
||||
|
||||
test('Portuguese summary values are translated', () => {
|
||||
inLocale('pt', () => {
|
||||
assert.equal(summaryCalLabel('raw-prior'), 'ESTIMATIVA BRUTA');
|
||||
assert.equal(summaryCalLabel('calibrated'), 'CALIBRADA');
|
||||
assert.equal(summarySourceLabel('IMAGE'), 'IMAGEM');
|
||||
assert.equal(summarySourceLabel('mjpeg'), 'MJPEG');
|
||||
assert.equal(summaryViewBandLabel('street'), 'RUA');
|
||||
assert.equal(summaryViewBandLabel('city'), 'CIDADE');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './labels.js';
|
||||
import {
|
||||
formatUtcDayMonth,
|
||||
isolate,
|
||||
t,
|
||||
tn,
|
||||
msg,
|
||||
@@ -25,9 +26,11 @@ import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
|
||||
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
// The clock is isolated (a no-op outside RTL): a no-break space between two
|
||||
// numbers joins them into one run, which in Arabic split the date around it.
|
||||
const utc = (value) =>
|
||||
value
|
||||
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${value.slice(11, 16)}\u00a0UTC`
|
||||
? `${formatUtcDayMonth(value).replace('-', '-\u2060')}\u00a0${isolate(value.slice(11, 16))}\u00a0UTC`
|
||||
: t('Unavailable');
|
||||
const COVERAGE = msg(
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.',
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { readResponseJsonCapped } from '../../sources/httpBody.js';
|
||||
|
||||
// Shared with the AI tools, so no i18n import: these errors stay English here
|
||||
// and the cyclones layer translates them where it shows them (translateKnown).
|
||||
const msg = (text) => text;
|
||||
|
||||
export const CYCLONE_RESPONSE_LIMIT = 4 * 1024 * 1024;
|
||||
const malformed = () => new Error('Malformed cyclone snapshot');
|
||||
const malformed = () => new Error(msg('Malformed cyclone snapshot'));
|
||||
const text = (value, max = 160) => {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
@@ -215,7 +219,7 @@ export function createCycloneSource({
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error('Cyclone request timed out')),
|
||||
() => controller.abort(new Error(msg('Cyclone request timed out'))),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
|
||||
@@ -18,9 +18,19 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { roadLabel } from '../../data/routeSteps.js';
|
||||
import { t, msg, formatNumber, translateKnown } from '../../i18n/index.js';
|
||||
import {
|
||||
t,
|
||||
msg,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { pointerOwnerName } from '../../data/pointerOwnerNames.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
formatQuantity,
|
||||
quantitySeparator,
|
||||
unitLabel,
|
||||
} from '../../ui/unitLabels.js';
|
||||
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_ID = 'directions-step';
|
||||
export const DIRECTIONS_STEP_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -38,33 +48,21 @@ export const DIRECTIONS_MODES = Object.freeze({
|
||||
export const DEFAULT_DIRECTIONS_MODE = 'car';
|
||||
|
||||
/**
|
||||
* The route proxy's refusal texts (server/providers/places/routes.js). They
|
||||
* stay English on the wire and in the AI tools; the row shows them translated.
|
||||
* The row error for a failed route request (createDirectionsSource errors),
|
||||
* as English data; the Layers panel shows it translated (ROUTING_ERROR_PATTERNS
|
||||
* in src/ui/statusText.js).
|
||||
*/
|
||||
const ROUTING_REFUSALS = new Set([
|
||||
msg('rate limited'),
|
||||
msg('routing service is rate limited'),
|
||||
msg('route leg too long'),
|
||||
msg('route too long'),
|
||||
]);
|
||||
|
||||
/** A refusal text from the route proxy in the language; unknown ones as given. */
|
||||
export function presentRoutingRefusal(text) {
|
||||
return ROUTING_REFUSALS.has(text) ? t(text) : text;
|
||||
}
|
||||
|
||||
/** The row text for a failed route request (createDirectionsSource errors). */
|
||||
export function presentRoutingFailure(error) {
|
||||
if (error?.status === 429) {
|
||||
return error.refusal
|
||||
? t('{error} — try again in a moment', {
|
||||
error: presentRoutingRefusal(error.refusal),
|
||||
})
|
||||
: t('Routing is rate limited — try again in a moment');
|
||||
? // i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||
`${error.refusal} — try again in a moment`
|
||||
: msg('Routing is rate limited — try again in a moment');
|
||||
}
|
||||
if (Number.isFinite(error?.status) && error.status > 0)
|
||||
return t('Routing unavailable (HTTP {status})', { status: error.status });
|
||||
return error?.message || t('Routing unavailable');
|
||||
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||
return `Routing unavailable (HTTP ${error.status})`;
|
||||
return error?.message || msg('Routing unavailable');
|
||||
}
|
||||
|
||||
/** Route colour — the annotation palette's cyan, so voice routes and Directions match. */
|
||||
@@ -138,7 +136,9 @@ export function routeDurationText(seconds) {
|
||||
if (minutes < 60) return formatQuantity(minutes, 'min');
|
||||
const hours = formatQuantity(Math.floor(minutes / 60), 'h');
|
||||
const rest = minutes % 60;
|
||||
return rest ? `${hours}\u00a0${formatQuantity(rest, 'min')}` : hours;
|
||||
if (!rest) return hours;
|
||||
// "1 h 5 min"; Japanese runs the parts together: 1時間5分.
|
||||
return `${hours}${quantitySeparator(unitLabel('h'))}${formatQuantity(rest, 'min')}`;
|
||||
}
|
||||
|
||||
/** "left", "slightly right", … as it reads inside a maneuver sentence. */
|
||||
@@ -407,6 +407,16 @@ export function normalizeDirectionsParams(params = {}) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A travel-mode word inside a sentence: lower case, except in German, where
|
||||
* the words are nouns and keep their capital ("Auto", "Fahrrad").
|
||||
* @param {string} word Translated mode word.
|
||||
* @returns {string}
|
||||
*/
|
||||
function modeWordInSentence(word) {
|
||||
return getLocale() === 'de' ? word : word.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Row chips for a given state. Pure so the chip logic is testable without a DOM.
|
||||
* @param {{mode:string, armed:null|'a'|'b', a:object|null, b:object|null, status:string, route:object|null}} state
|
||||
@@ -422,7 +432,7 @@ export function directionsRowControls(state) {
|
||||
state: mode === id ? 'active' : 'idle',
|
||||
title: t('{mode} — reroute for {modeLower}', {
|
||||
mode: t(spec.word),
|
||||
modeLower: t(spec.word).toLowerCase(),
|
||||
modeLower: modeWordInSentence(t(spec.word)),
|
||||
}),
|
||||
params: { mode: id },
|
||||
}));
|
||||
@@ -660,7 +670,9 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: route?.steps.length || 0,
|
||||
lastUpdate,
|
||||
error: t('Another map tool is using clicks — close it, then SET A again'),
|
||||
error: msg(
|
||||
'Another map tool is using clicks — close it, then SET A again',
|
||||
),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -679,7 +691,7 @@ export function directionsStats(state) {
|
||||
return {
|
||||
count: 0,
|
||||
lastUpdate,
|
||||
error: error || t('No route found'),
|
||||
error: error || msg('No route found'),
|
||||
status: 'empty',
|
||||
source,
|
||||
};
|
||||
@@ -1161,13 +1173,10 @@ export function createDirectionsLayer({ services, source }) {
|
||||
clearRouteGraphics();
|
||||
_status = 'error';
|
||||
_error =
|
||||
payload?.error === 'no route found'
|
||||
? t('No route found between A and B')
|
||||
: payload?.error
|
||||
? t('Routing failed: {error}', {
|
||||
error: presentRoutingRefusal(payload.error),
|
||||
})
|
||||
: t('No route found between A and B');
|
||||
// i18n-ignore: English data, shown through ROUTING_ERROR_PATTERNS
|
||||
payload?.error && payload.error !== 'no route found'
|
||||
? `Routing failed: ${payload.error}`
|
||||
: msg('No route found between A and B');
|
||||
return;
|
||||
}
|
||||
_route = route;
|
||||
@@ -1184,9 +1193,9 @@ export function createDirectionsLayer({ services, source }) {
|
||||
// happened instead.
|
||||
_error =
|
||||
error?.name === 'AbortError'
|
||||
? t('Routing timed out')
|
||||
? msg('Routing timed out')
|
||||
: error?.name === 'TypeError'
|
||||
? t('Routing unavailable — no response from the routing service')
|
||||
? msg('Routing unavailable — no response from the routing service')
|
||||
: presentRoutingFailure(error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Directions reports its fixed errors as English data (getStats(), and so the
|
||||
// layer snapshot the voice tools and the analyst read); the Layers panel row
|
||||
// shows them in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale, t as translate } from '../../i18n/index.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { directionsStats, presentRoutingFailure } from './index.js';
|
||||
|
||||
const idle = {
|
||||
enabled: true,
|
||||
mode: 'car',
|
||||
armed: null,
|
||||
a: null,
|
||||
b: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
route: null,
|
||||
lastUpdate: null,
|
||||
};
|
||||
|
||||
const cases = [
|
||||
[
|
||||
{ ...idle, pointerBlocked: true },
|
||||
'Another map tool is using clicks — close it, then SET A again',
|
||||
],
|
||||
[{ ...idle, status: 'error' }, 'No route found'],
|
||||
[
|
||||
{ ...idle, status: 'error', error: 'Routing timed out' },
|
||||
'Routing timed out',
|
||||
],
|
||||
];
|
||||
|
||||
function rowFor(stats) {
|
||||
return LayerPanel.prototype._buildMetaText({
|
||||
id: 'directions',
|
||||
enabled: true,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
// Route request failures with the proxy's refusal or an HTTP status in them:
|
||||
// [the request error, its English row error, the row text in the language]
|
||||
const failures = () => [
|
||||
[
|
||||
{ status: 429, refusal: 'route leg too long' },
|
||||
'route leg too long — try again in a moment',
|
||||
translate('{error} — try again in a moment', {
|
||||
error: translate('route leg too long'),
|
||||
}),
|
||||
],
|
||||
[
|
||||
{ status: 429, refusal: null },
|
||||
'Routing is rate limited — try again in a moment',
|
||||
translate('Routing is rate limited — try again in a moment'),
|
||||
],
|
||||
[
|
||||
{ status: 503 },
|
||||
'Routing unavailable (HTTP 503)',
|
||||
translate('Routing unavailable (HTTP {status})', { status: 503 }),
|
||||
],
|
||||
[
|
||||
{ status: 0, message: '' },
|
||||
'Routing unavailable',
|
||||
translate('Routing unavailable'),
|
||||
],
|
||||
];
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: Directions errors stay English in stats and read translated in the row`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
for (const [state, english] of cases) {
|
||||
const stats = directionsStats(state);
|
||||
assert.equal(stats.error, english);
|
||||
const row = rowFor(stats);
|
||||
assert.ok(row.includes(translate(english)), `${locale}: ${row}`);
|
||||
assert.ok(!row.includes(english), `${locale}: ${row}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: route request failures stay English in stats and read translated in the row`, (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale(locale);
|
||||
for (const [failure, english, shown] of failures()) {
|
||||
const error = presentRoutingFailure(failure);
|
||||
assert.equal(error, english);
|
||||
const stats = directionsStats({ ...idle, status: 'error', error });
|
||||
assert.equal(stats.error, english);
|
||||
assert.ok(rowFor(stats).includes(shown), `${locale}: ${rowFor(stats)}`);
|
||||
assert.notEqual(shown, english);
|
||||
}
|
||||
// The proxy's refusal after "Routing failed:", and a text it never sends.
|
||||
for (const [refusal, shownRefusal] of [
|
||||
['route too long', translate('route too long')],
|
||||
['something else', 'something else'],
|
||||
]) {
|
||||
const row = rowFor(
|
||||
directionsStats({
|
||||
...idle,
|
||||
status: 'error',
|
||||
error: `Routing failed: ${refusal}`,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
row.includes(
|
||||
translate('Routing failed: {error}', { error: shownRefusal }),
|
||||
),
|
||||
`${locale}: ${row}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('en: the Directions row shows the English error', () => {
|
||||
setLocale('en');
|
||||
const row = rowFor(directionsStats({ ...idle, status: 'error' }));
|
||||
assert.match(row, /No route found/);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { formatNumber, getLocale, isolate, t, tn } from '../../i18n/index.js';
|
||||
import {
|
||||
satelliteShortName,
|
||||
fireDetectionKey,
|
||||
@@ -407,7 +407,6 @@ export function createModel({
|
||||
|
||||
function buildCellCard(candidate, nowMs) {
|
||||
const cell = candidate.cell;
|
||||
const noun = cell.count === 1 ? t('FIRE') : t('FIRES');
|
||||
const parts = [t('max {value} MW', { value: formatFrpLabel(cell.maxFrp) })];
|
||||
if (cell.newestAcqMs > 0) {
|
||||
const age = formatAgeLabel(nowMs - cell.newestAcqMs);
|
||||
@@ -419,7 +418,12 @@ export function createModel({
|
||||
cullPosition: candidate.cullPosition || candidate.position,
|
||||
gapPx: 10,
|
||||
accent: candidate.accent || accentForSeverity('yellow'),
|
||||
title: `${cell.count} ${noun}`,
|
||||
// The count picks the plural form of the language ("حريقان (2)",
|
||||
// "14 حريقًا", "14 ПОЖАРОВ"); the card shows it in capitals like the
|
||||
// other card titles.
|
||||
title: tn(cell.count, '{n} fire', '{n} fires', {
|
||||
n: cell.count,
|
||||
}).toLocaleUpperCase(getLocale()),
|
||||
details: [parts.join(' · ')],
|
||||
selected: false,
|
||||
priority: Number(cell.maxFrp) || 0,
|
||||
@@ -513,7 +517,7 @@ export function createModel({
|
||||
lon >= 0
|
||||
? t('{value}°E', { value: amount(lon) })
|
||||
: t('{value}°W', { value: amount(lon) });
|
||||
return `${latPart} ${lonPart}`;
|
||||
return `${isolate(latPart)} ${isolate(lonPart)}`;
|
||||
}
|
||||
|
||||
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createCivilFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createEnrichment } from './enrichment.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
@@ -124,3 +125,24 @@ test('a synchronous enrichment failure stays bounded and releases its slot', asy
|
||||
await new Promise((done) => setImmediate(done));
|
||||
assert.equal(flightState._enrichActive, 0);
|
||||
});
|
||||
|
||||
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
|
||||
const tracking = createTracking({
|
||||
flightState: {
|
||||
feed: { _lastSource: 'Fixture aircraft' },
|
||||
records: { data: new Map() },
|
||||
},
|
||||
services: services(),
|
||||
parts: {
|
||||
queries: {
|
||||
_describeFlight: () => ({ track: 359.6, altitudeM: 9000 }),
|
||||
_contactLabel: () => 'AAL1',
|
||||
},
|
||||
},
|
||||
layer: {},
|
||||
});
|
||||
assert.equal(
|
||||
tracking._contextSubjectMetadata('abc123').properties.heading,
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -126,7 +127,7 @@ export function createTracking({
|
||||
? `${Math.round(described.velocityMps * 1.944)} kt`
|
||||
: '',
|
||||
heading: Number.isFinite(described.track)
|
||||
? `${Math.round(described.track)}°`
|
||||
? `${roundHeadingDegrees(described.track)}°`
|
||||
: '',
|
||||
route: route || '',
|
||||
icao24,
|
||||
|
||||
@@ -70,10 +70,16 @@ export function createGnssIntegrityLayer({
|
||||
viewAnchor = defaultGnssViewAnchor,
|
||||
now = () => Date.now(),
|
||||
monotonicNow = () => performance.now(),
|
||||
windowMs = GNSS_WINDOW_MS,
|
||||
windowMs: requestedWindowMs,
|
||||
} = {}) {
|
||||
if (typeof source?.getSnapshot !== 'function')
|
||||
throw new TypeError('GNSS integrity layer requires a snapshot source');
|
||||
// A source may set its own evidence window; anything but a positive finite
|
||||
// duration keeps the default.
|
||||
const windowMs =
|
||||
Number.isFinite(requestedWindowMs) && requestedWindowMs > 0
|
||||
? requestedWindowMs
|
||||
: GNSS_WINDOW_MS;
|
||||
let _viewer = null;
|
||||
let _primitives = null;
|
||||
let _request = null;
|
||||
@@ -375,7 +381,8 @@ export function createGnssIntegrityLayer({
|
||||
chips: [],
|
||||
info:
|
||||
t(
|
||||
'Regional sample · 250 nm radius · cells from visited views over 30 min',
|
||||
'Regional sample · 250 nm radius · cells from visited views over {minutes} min',
|
||||
{ minutes: formatNumber(provenance.window.minutes) },
|
||||
) +
|
||||
(_lastError && _retryAt
|
||||
? ` · ${translateFeedError(_lastError)} · ${
|
||||
|
||||
@@ -42,7 +42,10 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion.methods,
|
||||
parts.panel.commands,
|
||||
{
|
||||
subscribePanel: parts.panel.subscribePanel,
|
||||
subscribeReplayOverlay: parts.overlays.subscribeReplayOverlay,
|
||||
satelliteParamsForSpaceMissions:
|
||||
parts.policyHelpers.satelliteParamsForSpaceMissions,
|
||||
satelliteParamsAfterSpaceMissions:
|
||||
@@ -69,10 +72,6 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
missionHoverPreviewRange: parts.panel.missionHoverPreviewRange,
|
||||
createMissionRosterPreviewOwnership:
|
||||
parts.panel.createMissionRosterPreviewOwnership,
|
||||
bindMissionRosterItemKeyboardPreview:
|
||||
parts.panel.bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus: parts.panel.captureMissionRosterFocus,
|
||||
restoreMissionRosterFocus: parts.panel.restoreMissionRosterFocus,
|
||||
resolveMissionRosterPreviewLaunch:
|
||||
parts.panel.resolveMissionRosterPreviewLaunch,
|
||||
formatMissionEventTime: parts.policyHelpers.formatMissionEventTime,
|
||||
|
||||
@@ -87,6 +87,7 @@ async function fixture({ legacySource = false } = {}) {
|
||||
renderMissionRoster: noop,
|
||||
renderMissionPanel: noop,
|
||||
clearMissionRosterHover: noop,
|
||||
clearMissionPanel: noop,
|
||||
},
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
|
||||
@@ -201,7 +201,6 @@ export function createLifecycle({
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
parts.selection.focusFullGlobe(layerState._viewer);
|
||||
document.getElementById('cockpit-context')?.setAttribute('hidden', '');
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.selection.setSelectedMission(
|
||||
layerState._selectedLaunchId,
|
||||
@@ -262,13 +261,7 @@ export function createLifecycle({
|
||||
layerState._animationStarts.clear();
|
||||
layerState._satelliteTelemetry.clear();
|
||||
layerState._replayTracks.clear();
|
||||
layerState._missionPanel?.remove();
|
||||
layerState._missionPanel = null;
|
||||
if (layerState._missionRoster) {
|
||||
layerState._missionRoster.onclick = null;
|
||||
}
|
||||
layerState._missionRosterPreviewOwnership = null;
|
||||
layerState._missionRoster = null;
|
||||
parts.panel.clearMissionPanel();
|
||||
layerState._viewer = null;
|
||||
if (layerState._dataSource)
|
||||
viewer.dataSources.remove(layerState._dataSource, true);
|
||||
|
||||
@@ -13,20 +13,7 @@ import { createPanel } from './panel.js';
|
||||
const NOW = new Date('2026-07-27T00:00:00Z');
|
||||
|
||||
function shownStatus(status) {
|
||||
const outputs = new Map();
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector(selector) {
|
||||
if (!outputs.has(selector))
|
||||
outputs.set(selector, {
|
||||
textContent: '',
|
||||
closest: () => null,
|
||||
removeAttribute() {},
|
||||
});
|
||||
return outputs.get(selector);
|
||||
},
|
||||
closest: () => null,
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [
|
||||
{
|
||||
@@ -39,15 +26,14 @@ function shownStatus(status) {
|
||||
],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
createPanel({
|
||||
return createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
replay: { presentReplay: () => ({}) },
|
||||
},
|
||||
}).renderMissionPanel();
|
||||
return outputs.get('[data-mission-status]').textContent;
|
||||
}).presentMission().status;
|
||||
}
|
||||
|
||||
test('a launch without a status keeps the English fallback in its record', () => {
|
||||
|
||||
@@ -280,60 +280,64 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
return ascending ? 'ascent' : 'orbit';
|
||||
}
|
||||
|
||||
function createReplayVehicleOverlay() {
|
||||
if (layerState._replayVehicleOverlay || typeof document === 'undefined')
|
||||
/**
|
||||
* Present the replay vehicle overlay to its subscribers, or null to remove
|
||||
* it. An unchanged presentation is not sent again.
|
||||
*/
|
||||
function presentReplayVehicleOverlay(patch) {
|
||||
const current = layerState._replayVehicleOverlay;
|
||||
const next = patch && { ...current, ...patch };
|
||||
if (
|
||||
next === current ||
|
||||
(next &&
|
||||
current &&
|
||||
Object.keys(next).every((k) => next[k] === current[k]))
|
||||
)
|
||||
return;
|
||||
const host = document.getElementById('cesiumContainer') || document.body;
|
||||
layerState._replayVehicleOverlay = document.createElement('div');
|
||||
layerState._replayVehicleOverlay.className =
|
||||
'mission-replay-vehicle-overlay';
|
||||
layerState._replayVehicleOverlay.hidden = true;
|
||||
layerState._replayVehicleOverlay.setAttribute('aria-hidden', 'true');
|
||||
layerState._replayVehicleOverlay.innerHTML = `
|
||||
<div class="mission-replay-flight-symbol">
|
||||
<svg class="mission-replay-rocket" viewBox="0 0 48 72" aria-hidden="true">
|
||||
<path class="mission-replay-rocket-body" d="M24 5C16 14 14 27 15 45L9 54L18 51L24 58L30 51L39 54L33 45C34 27 32 14 24 5Z"></path>
|
||||
<circle class="mission-replay-rocket-port" cx="24" cy="29" r="3.4"></circle>
|
||||
</svg>
|
||||
<svg class="mission-replay-thrust" viewBox="0 0 72 72" aria-hidden="true">
|
||||
<ellipse style="--thrust-index:0" cx="36" cy="7" rx="5" ry="1.8"></ellipse>
|
||||
<ellipse style="--thrust-index:1" cx="36" cy="15" rx="8" ry="2.4"></ellipse>
|
||||
<ellipse style="--thrust-index:2" cx="36" cy="24" rx="11" ry="3"></ellipse>
|
||||
<ellipse style="--thrust-index:3" cx="36" cy="35" rx="15" ry="3.8"></ellipse>
|
||||
<ellipse style="--thrust-index:4" cx="36" cy="48" rx="20" ry="4.7"></ellipse>
|
||||
<ellipse style="--thrust-index:5" cx="36" cy="63" rx="26" ry="5.8"></ellipse>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mission-replay-orbit-dot" aria-hidden="true"></div>
|
||||
<div class="mission-replay-overlay-callout">
|
||||
<strong data-replay-overlay-title></strong>
|
||||
<span data-replay-overlay-detail></span>
|
||||
</div>`;
|
||||
host.appendChild(layerState._replayVehicleOverlay);
|
||||
layerState._replayVehicleOverlay._sonarIcons = [
|
||||
...layerState._replayVehicleOverlay.querySelectorAll(
|
||||
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
|
||||
),
|
||||
];
|
||||
layerState._replayVehicleOverlay._sonarLabel =
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'.mission-replay-overlay-callout',
|
||||
);
|
||||
layerState._replayVehicleOverlay = next;
|
||||
for (const listener of [...layerState._replayVehicleOverlayListeners])
|
||||
listener(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive every presentation of the replay vehicle overlay, starting with
|
||||
* the current one.
|
||||
* @param {(view: ?object) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribeReplayOverlay(listener) {
|
||||
layerState._replayVehicleOverlayListeners.add(listener);
|
||||
listener(layerState._replayVehicleOverlay);
|
||||
return () => layerState._replayVehicleOverlayListeners.delete(listener);
|
||||
}
|
||||
|
||||
function createReplayVehicleOverlay() {
|
||||
if (layerState._replayVehicleOverlay) return;
|
||||
presentReplayVehicleOverlay({
|
||||
visible: false,
|
||||
mode: null,
|
||||
thrusting: false,
|
||||
paused: false,
|
||||
transform: null,
|
||||
rotation: null,
|
||||
iconOpacity: null,
|
||||
labelOpacity: null,
|
||||
title: '',
|
||||
detail: '',
|
||||
});
|
||||
}
|
||||
|
||||
function hideReplayVehicleOverlay() {
|
||||
if (!layerState._replayVehicleOverlay) return;
|
||||
layerState._replayVehicleOverlay.hidden = true;
|
||||
layerState._replayVehicleOverlay.classList.remove(
|
||||
'is-thrusting',
|
||||
'is-paused',
|
||||
);
|
||||
presentReplayVehicleOverlay({
|
||||
visible: false,
|
||||
thrusting: false,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
|
||||
function destroyReplayVehicleOverlay() {
|
||||
layerState._replayVehicleOverlay?.remove();
|
||||
layerState._replayVehicleOverlay = null;
|
||||
layerState._replayVehicleOverlayText = '';
|
||||
presentReplayVehicleOverlay(null);
|
||||
}
|
||||
|
||||
function clearMissionOverlaySources() {
|
||||
@@ -523,23 +527,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
: null;
|
||||
const factor =
|
||||
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
|
||||
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
|
||||
icon.style.opacity = String(factor);
|
||||
if (layerState._replayVehicleOverlay._sonarLabel)
|
||||
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
|
||||
sonar ? sonar.label(factor) : 1,
|
||||
);
|
||||
layerState._replayVehicleOverlay.hidden = false;
|
||||
layerState._replayVehicleOverlay.dataset.mode = mode;
|
||||
layerState._replayVehicleOverlay.classList.toggle(
|
||||
'is-thrusting',
|
||||
replayActive && state.ascending && !state.countdownActive,
|
||||
);
|
||||
layerState._replayVehicleOverlay.classList.toggle(
|
||||
'is-paused',
|
||||
replayActive && layerState._replayPaused,
|
||||
);
|
||||
layerState._replayVehicleOverlay.style.transform = `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`;
|
||||
const frame = {
|
||||
visible: true,
|
||||
mode,
|
||||
thrusting: replayActive && state.ascending && !state.countdownActive,
|
||||
paused: replayActive && layerState._replayPaused,
|
||||
transform: `translate3d(${renderedWindowPosition.x}px, ${renderedWindowPosition.y}px, 0) translate(-50%, -50%)`,
|
||||
iconOpacity: String(factor),
|
||||
labelOpacity: String(sonar ? sonar.label(factor) : 1),
|
||||
};
|
||||
let vehicleRotation = 0;
|
||||
if (replayActive && !state.countdownActive) {
|
||||
const tangentStep = Math.max(0.002, 0.8 / Math.max(2, path.length - 1));
|
||||
@@ -581,10 +577,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
}
|
||||
}
|
||||
layerState._replayVehicleOverlay.style.setProperty(
|
||||
'--mission-replay-rotation',
|
||||
`${Cesium.Math.toDegrees(vehicleRotation)}deg`,
|
||||
);
|
||||
frame.rotation = `${Cesium.Math.toDegrees(vehicleRotation)}deg`;
|
||||
|
||||
const mission = shortMissionLabel(launch.name, 22).toUpperCase();
|
||||
const siteName = compactLaunchSiteName(launch.launchSite);
|
||||
@@ -620,16 +613,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
detail = parts.policyHelpers.formatMissionEventTime(state.eventTime);
|
||||
}
|
||||
if (layerState._replayPaused) title = t('PAUSED · {title}', { title });
|
||||
const nextText = `${title}\n${detail}`;
|
||||
if (nextText !== layerState._replayVehicleOverlayText) {
|
||||
layerState._replayVehicleOverlayText = nextText;
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'[data-replay-overlay-title]',
|
||||
).textContent = title;
|
||||
layerState._replayVehicleOverlay.querySelector(
|
||||
'[data-replay-overlay-detail]',
|
||||
).textContent = detail;
|
||||
}
|
||||
presentReplayVehicleOverlay({ ...frame, title, detail });
|
||||
return mode;
|
||||
}
|
||||
return {
|
||||
@@ -643,6 +627,7 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
missionHoverReticleImage,
|
||||
replayOverlayMode,
|
||||
createReplayVehicleOverlay,
|
||||
subscribeReplayOverlay,
|
||||
hideReplayVehicleOverlay,
|
||||
destroyReplayVehicleOverlay,
|
||||
clearMissionOverlaySources,
|
||||
|
||||
+280
-363
@@ -1,11 +1,20 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatNumber, isolate, onLocaleChange, t } from '../../i18n/index.js';
|
||||
import {
|
||||
MISSION_CLOSE_VIEW_RANGE_M,
|
||||
MISSION_GLOBE_VIEW_RANGE_M,
|
||||
} from './policy.js';
|
||||
import { UNKNOWN_LAUNCH_STATUS } from './model.js';
|
||||
|
||||
// Landing endpoints carry an English accuracy code (the panel and the map
|
||||
// entity compare it); only the text shown in the stage table is translated.
|
||||
// The downrange estimate arrives already translated.
|
||||
function accuracyLabel(accuracy) {
|
||||
if (accuracy === 'CONFIRMED') return t('CONFIRMED');
|
||||
if (accuracy === 'PAD / RTLS') return t('PAD / RTLS');
|
||||
return accuracy;
|
||||
}
|
||||
|
||||
export function createPanel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Preserve the user's globe scale for roster previews while avoiding an
|
||||
@@ -88,68 +97,6 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind native keyboard focus events for one rendered roster row.
|
||||
* @param {Element} button Mission roster button.
|
||||
* @param {number} index Source-array mission index.
|
||||
* @param {{pointerEnter: (index: number) => void, pointerLeave: () => void,
|
||||
* focus: (index: number) => void, blur: (nextIndex?: number|null) => void}} ownership
|
||||
* @param {Element} roster Roster root used to reject focus targets outside the list.
|
||||
*/
|
||||
|
||||
function bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
ownership,
|
||||
roster,
|
||||
) {
|
||||
button.addEventListener('mouseenter', () => ownership.pointerEnter(index));
|
||||
button.addEventListener('mouseleave', () => ownership.pointerLeave());
|
||||
button.addEventListener('focus', () => ownership.focus(index));
|
||||
button.addEventListener('blur', (event) => {
|
||||
const nextButton = event.relatedTarget?.closest?.(
|
||||
'[data-mission-roster-index]',
|
||||
);
|
||||
const nextIndex =
|
||||
nextButton && roster.contains(nextButton)
|
||||
? Number(nextButton.dataset.missionRosterIndex)
|
||||
: null;
|
||||
ownership.blur(nextIndex);
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture the exact mission identity owned by keyboard focus before a refresh. */
|
||||
|
||||
function captureMissionRosterFocus(
|
||||
list,
|
||||
activeElement = globalThis.document?.activeElement,
|
||||
) {
|
||||
const button = activeElement?.closest?.('[data-mission-roster-id]');
|
||||
if (!button || !list?.contains(button)) return null;
|
||||
const launchId = button.dataset.missionRosterId;
|
||||
return launchId ? { launchId } : null;
|
||||
}
|
||||
|
||||
/** Restore focus by mission identity, or continue after the list if it departed. */
|
||||
|
||||
function restoreMissionRosterFocus(list, snapshot, continuation) {
|
||||
if (!snapshot?.launchId) return 'none';
|
||||
const button = Array.from(
|
||||
list?.querySelectorAll?.('[data-mission-roster-id]') || [],
|
||||
).find(
|
||||
(candidate) => candidate.dataset.missionRosterId === snapshot.launchId,
|
||||
);
|
||||
if (button) {
|
||||
button.focus({ preventScroll: true });
|
||||
return 'restored';
|
||||
}
|
||||
if (continuation?.focus) {
|
||||
continuation.focus({ preventScroll: true });
|
||||
return 'continued';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Resolve a pending preview against the current refresh, never a stale object. */
|
||||
|
||||
function resolveMissionRosterPreviewLaunch(launches, launchId) {
|
||||
@@ -158,213 +105,213 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
}
|
||||
|
||||
function missionTableRows(items, columns, emptyText) {
|
||||
if (!items.length)
|
||||
return `<tr><td colspan="${columns}" class="mission-table-empty">${emptyText}</td></tr>`;
|
||||
return items.map((item) => item).join('');
|
||||
/**
|
||||
* Publish a fresh view unless it is the one already shown. The roster
|
||||
* carries its own signature, so a telemetry update compares the mission.
|
||||
*/
|
||||
function publish(view) {
|
||||
const signature = `${layerState._missionRosterSignature}|${JSON.stringify(view.mission)}`;
|
||||
if (layerState._missionPanelSignature === signature) return;
|
||||
layerState._missionPanelSignature = signature;
|
||||
layerState._missionPanelView = view;
|
||||
for (const listener of [...layerState._missionPanelListeners])
|
||||
listener(view);
|
||||
}
|
||||
|
||||
function setMissionPanelField(selector, value, title = '') {
|
||||
const output = layerState._missionPanel?.querySelector(selector);
|
||||
if (!output) return;
|
||||
const row = output.closest('[data-mission-field]');
|
||||
const available =
|
||||
value !== null && value !== undefined && String(value).trim() !== '';
|
||||
if (row) row.hidden = !available;
|
||||
if (!available) {
|
||||
output.textContent = '';
|
||||
output.removeAttribute('title');
|
||||
return;
|
||||
}
|
||||
output.textContent = value;
|
||||
if (title) output.title = title;
|
||||
else output.removeAttribute('title');
|
||||
/**
|
||||
* Receive every published view, starting with the current one.
|
||||
* @param {(view: {roster: ?object, mission: ?object}) => void} listener
|
||||
* @returns {() => void} Unsubscribe.
|
||||
*/
|
||||
function subscribePanel(listener) {
|
||||
layerState._missionPanelListeners.add(listener);
|
||||
if (layerState._missionPanelView) listener(layerState._missionPanelView);
|
||||
return () => layerState._missionPanelListeners.delete(listener);
|
||||
}
|
||||
|
||||
function renderMissionPanel() {
|
||||
if (!layerState._missionPanel) return;
|
||||
/** Publish the roster and the selected mission as they stand. */
|
||||
function publishPanel() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
publish({
|
||||
roster: layerState._missionRosterView,
|
||||
mission: presentMission(),
|
||||
});
|
||||
}
|
||||
|
||||
/** A detail value, or null when the panel hides its row. */
|
||||
function presentField(value) {
|
||||
return value !== null && value !== undefined && String(value).trim() !== ''
|
||||
? String(value)
|
||||
: null;
|
||||
}
|
||||
|
||||
function presentPayload(payload, launch) {
|
||||
const detail = [
|
||||
payload.manufacturer,
|
||||
payload.operator && payload.operator !== payload.manufacturer
|
||||
? payload.operator
|
||||
: null,
|
||||
Number.isFinite(payload.massKg)
|
||||
? `${formatNumber(payload.massKg)} KG`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
name: `${payload.name ?? ''}${payload.amount > 1 ? ` ×${payload.amount}` : ''}`,
|
||||
detail,
|
||||
type: payload.type || t('UNSPECIFIED'),
|
||||
destination:
|
||||
payload.destination || launch.orbit?.name || t('UNAVAILABLE'),
|
||||
};
|
||||
}
|
||||
|
||||
function presentStage(stage, launch) {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
? launch.launchSite
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${isolate(`${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)}`)} · ${accuracyLabel(endpoint.accuracy)}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: formatNumber(stage.downrangeKm),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const detail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
name: String(stage.name ?? ''),
|
||||
detail,
|
||||
status: String(stage.status ?? ''),
|
||||
destination: String(destination ?? ''),
|
||||
position,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected mission as the panel shows it, or null when none is
|
||||
* selected and the panel hides. Text arrives in the UI language.
|
||||
*/
|
||||
function presentMission() {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
const index = launch ? layerState._launches.indexOf(launch) : -1;
|
||||
layerState._missionPanel.hidden = !launch;
|
||||
if (!launch) return;
|
||||
layerState._missionPanel.querySelector('[data-mission-title]').textContent =
|
||||
parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase();
|
||||
setMissionPanelField('[data-mission-provider]', launch.provider);
|
||||
// Launch Library status names are provider data, shown as sent; only the
|
||||
// fallback for a launch without one is ours to translate.
|
||||
setMissionPanelField(
|
||||
'[data-mission-status]',
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
);
|
||||
setMissionPanelField(
|
||||
'[data-mission-site]',
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
? launch.launchSite
|
||||
: null,
|
||||
);
|
||||
setMissionPanelField('[data-mission-time]', launch.launchTime);
|
||||
if (!launch) return null;
|
||||
const index = layerState._launches.indexOf(launch);
|
||||
const pathPresentation = parts.policyHelpers.missionPathPresentation(
|
||||
launch,
|
||||
layerState._replayTracks.has(launch.id),
|
||||
);
|
||||
setMissionPanelField('[data-mission-orbit]', pathPresentation.orbit);
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-ascent-source]',
|
||||
).textContent = pathPresentation.ascent;
|
||||
const payloadRows = launch.payloads.length
|
||||
? launch.payloads.slice(0, 5).map((payload) => {
|
||||
const detail = [
|
||||
payload.manufacturer,
|
||||
payload.operator && payload.operator !== payload.manufacturer
|
||||
? payload.operator
|
||||
: null,
|
||||
Number.isFinite(payload.massKg)
|
||||
? `${payload.massKg.toLocaleString()} KG`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(payload.name)}${payload.amount > 1 ? ` ×${payload.amount}` : ''}${detail ? `<small>${escapeMissionText(detail)}</small>` : ''}</td><td>${escapeMissionText(payload.type || t('UNSPECIFIED'))}</td><td>${escapeMissionText(payload.destination || launch.orbit?.name || t('UNAVAILABLE'))}</td></tr>`;
|
||||
})
|
||||
: [];
|
||||
if (launch.payloads.length > 5) {
|
||||
payloadRows.push(
|
||||
`<tr><td colspan="3" class="mission-table-empty">${t('+{count} additional payload records', { count: launch.payloads.length - 5 })}</td></tr>`,
|
||||
);
|
||||
}
|
||||
layerState._missionPanel.querySelector(
|
||||
'[data-mission-payloads]',
|
||||
).innerHTML = missionTableRows(
|
||||
payloadRows,
|
||||
3,
|
||||
t('PAYLOAD DATA UNAVAILABLE'),
|
||||
);
|
||||
const stageRows = launch.recoveryStages.map((stage) => {
|
||||
const endpoint = stage.endpoint;
|
||||
const destination =
|
||||
stage.destination ||
|
||||
(endpoint?.accuracy === 'PAD / RTLS'
|
||||
const telemetry = layerState._missionTelemetry;
|
||||
return {
|
||||
scrollRequest: layerState._missionPanelScrollRequest,
|
||||
title: parts.overlays.shortMissionLabel(launch.name, 32).toUpperCase(),
|
||||
provider: presentField(launch.provider),
|
||||
// Launch Library status names are provider data, shown as sent; only
|
||||
// the fallback for a launch without one is ours to translate.
|
||||
status: presentField(
|
||||
launch.status === UNKNOWN_LAUNCH_STATUS
|
||||
? t(UNKNOWN_LAUNCH_STATUS)
|
||||
: launch.status,
|
||||
),
|
||||
site: presentField(
|
||||
launch.launchSite && launch.launchSite !== 'Unknown launch site'
|
||||
? launch.launchSite
|
||||
: t('UNAVAILABLE'));
|
||||
const position = endpoint
|
||||
? `${endpoint.lat.toFixed(2)}, ${endpoint.lon.toFixed(2)} · ${endpoint.accuracy}`
|
||||
: stage.downrangeKm > 0
|
||||
? t('{value} KM DOWNRANGE', {
|
||||
value: stage.downrangeKm.toLocaleString(),
|
||||
})
|
||||
: t('POSITION UNAVAILABLE');
|
||||
const stageDetail = [
|
||||
Number.isFinite(stage.flightNumber)
|
||||
? t('FLIGHT {count}', {
|
||||
count: stage.flightNumber,
|
||||
: null,
|
||||
),
|
||||
time: presentField(launch.launchTime),
|
||||
orbit: presentField(pathPresentation.orbit),
|
||||
ascent: pathPresentation.ascent ?? '',
|
||||
distance: telemetry?.distance ?? null,
|
||||
speed: telemetry?.speed ?? null,
|
||||
speedTitle: telemetry?.speedTitle ?? null,
|
||||
payloads: launch.payloads
|
||||
.slice(0, 5)
|
||||
.map((payload) => presentPayload(payload, launch)),
|
||||
payloadsMore:
|
||||
launch.payloads.length > 5
|
||||
? t('+{count} additional payload records', {
|
||||
count: launch.payloads.length - 5,
|
||||
})
|
||||
: null,
|
||||
stage.reused ? t('REUSED') : null,
|
||||
stage.recoveryType,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return `<tr><td>${escapeMissionText(stage.name)}${stageDetail ? `<small>${escapeMissionText(stageDetail)}</small>` : ''}</td><td>${escapeMissionText(stage.status)}</td><td>${escapeMissionText(destination)}<small>${escapeMissionText(position)}</small></td></tr>`;
|
||||
});
|
||||
layerState._missionPanel.querySelector('[data-mission-stages]').innerHTML =
|
||||
missionTableRows(stageRows, 3, t('NO STAGE RE-ENTRY / RECOVERY DATA'));
|
||||
const stageSection = layerState._missionPanel.querySelector(
|
||||
'[data-mission-stages-section]',
|
||||
);
|
||||
if (stageSection) stageSection.hidden = stageRows.length === 0;
|
||||
updateMissionTelemetry(true);
|
||||
layerState._missionPanel.querySelector('[data-mission-index]').textContent =
|
||||
`${index + 1} / ${layerState._launches.length}`;
|
||||
layerState._missionPanel.querySelector('[data-mission-prev]').disabled =
|
||||
index <= 0;
|
||||
layerState._missionPanel.querySelector('[data-mission-next]').disabled =
|
||||
index < 0 || index >= layerState._launches.length - 1;
|
||||
parts.replay.syncReplayButton();
|
||||
const panelScroller = layerState._missionPanel.closest(
|
||||
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
|
||||
);
|
||||
if (panelScroller) panelScroller.scrollTop = 0;
|
||||
stages: launch.recoveryStages.map((stage) => presentStage(stage, launch)),
|
||||
index: `${index + 1} / ${layerState._launches.length}`,
|
||||
previousDisabled: index <= 0,
|
||||
nextDisabled: index < 0 || index >= layerState._launches.length - 1,
|
||||
replay: parts.replay.presentReplay(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderMissionRoster() {
|
||||
if (!layerState._missionRoster) return;
|
||||
const list = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-list]',
|
||||
);
|
||||
const count = layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-count]',
|
||||
);
|
||||
if (count)
|
||||
count.textContent = t('{count} / 30D', {
|
||||
count: layerState._launches.length,
|
||||
});
|
||||
if (!list) return;
|
||||
const focusSnapshot = captureMissionRosterFocus(list);
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
const entries = parts.policyHelpers.missionRosterEntries(
|
||||
layerState._launches,
|
||||
);
|
||||
if (!entries.length) {
|
||||
list.innerHTML = `<div class="space-mission-roster-empty">${t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW')}</div>`;
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-focus-continuation]',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = entries
|
||||
/** Present the newly selected mission and return its panel to the top. */
|
||||
function renderMissionPanel() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
layerState._missionTelemetry = null;
|
||||
updateMissionTelemetry(true, false);
|
||||
layerState._missionPanelScrollRequest++;
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** The roster entries as the list shows them. */
|
||||
function presentRoster() {
|
||||
const entries = parts.policyHelpers
|
||||
.missionRosterEntries(layerState._launches)
|
||||
.map(({ launch, index }) => {
|
||||
const color = parts.model.missionMarkerColor(launch).toCssColorString();
|
||||
const date = launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE');
|
||||
const provider = launch.provider || t('UNSPECIFIED OPERATOR');
|
||||
const label = parts.overlays
|
||||
.shortMissionLabel(launch.name, 27)
|
||||
.toUpperCase();
|
||||
return `<button type="button" class="space-mission-roster-item" data-mission-roster-index="${index}" data-mission-roster-id="${escapeMissionText(launch.id)}" aria-label="${escapeMissionText(t('Select {mission}', { mission: label }))}"><span class="space-mission-roster-marker" style="--mission-roster-color:${color}" aria-hidden="true"></span><span class="space-mission-roster-copy"><strong>${escapeMissionText(label)}</strong><small>${escapeMissionText(provider)} · ${escapeMissionText(date)}</small></span><span class="space-mission-roster-chevron" aria-hidden="true">›</span></button>`;
|
||||
})
|
||||
.join('');
|
||||
list.querySelectorAll('[data-mission-roster-index]').forEach((button) => {
|
||||
const index = Number(button.dataset.missionRosterIndex);
|
||||
bindMissionRosterItemKeyboardPreview(
|
||||
button,
|
||||
index,
|
||||
layerState._missionRosterPreviewOwnership,
|
||||
layerState._missionRoster,
|
||||
);
|
||||
});
|
||||
restoreMissionRosterFocus(
|
||||
list,
|
||||
focusSnapshot,
|
||||
layerState._missionRoster.querySelector(
|
||||
'[data-mission-roster-focus-continuation]',
|
||||
),
|
||||
);
|
||||
return {
|
||||
index,
|
||||
id: String(launch.id ?? ''),
|
||||
color: parts.model.missionMarkerColor(launch).toCssColorString(),
|
||||
label,
|
||||
ariaLabel: t('Select {mission}', { mission: label }),
|
||||
provider: launch.provider || t('UNSPECIFIED OPERATOR'),
|
||||
date: launch.launchTime?.slice(0, 10) || t('DATE UNAVAILABLE'),
|
||||
};
|
||||
});
|
||||
return {
|
||||
count: t('{count} / 30D', { count: layerState._launches.length }),
|
||||
entries,
|
||||
empty: t('NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW'),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeMissionText(value) {
|
||||
return String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(character) =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character],
|
||||
);
|
||||
/**
|
||||
* Present the roster. A changed roster drops the hover and keyboard
|
||||
* preview; the list restores keyboard focus by mission identity, which
|
||||
* resumes the preview.
|
||||
*/
|
||||
function renderMissionRoster() {
|
||||
if (!layerState._missionPanelReady) return;
|
||||
const roster = presentRoster();
|
||||
const signature = JSON.stringify(roster);
|
||||
if (signature !== layerState._missionRosterSignature) {
|
||||
layerState._missionRosterSignature = signature;
|
||||
layerState._missionRosterView = roster;
|
||||
layerState._missionRosterPreviewOwnership?.reset();
|
||||
}
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
function updateMissionTelemetry(force = false) {
|
||||
/**
|
||||
* Read the selected mission's altitude and speed, at most four times a
|
||||
* second unless forced, and republish when they change.
|
||||
*/
|
||||
function updateMissionTelemetry(force = false, republish = true) {
|
||||
if (
|
||||
!layerState._missionPanel ||
|
||||
!layerState._missionPanelReady ||
|
||||
!layerState._selectedLaunchId ||
|
||||
!layerState._dataSource
|
||||
)
|
||||
@@ -381,33 +328,30 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
const altitudeM = position
|
||||
? Cesium.Cartographic.fromCartesian(position)?.height
|
||||
: null;
|
||||
setMissionPanelField(
|
||||
'[data-mission-distance]',
|
||||
Number.isFinite(altitudeM)
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
layerState._selectedLaunchId,
|
||||
)?.speedMps;
|
||||
layerState._missionTelemetry = {
|
||||
distance: Number.isFinite(altitudeM)
|
||||
? t('{value} KM', {
|
||||
value: Math.max(0, altitudeM / 1000).toLocaleString(undefined, {
|
||||
value: formatNumber(Math.max(0, altitudeM / 1000), {
|
||||
maximumFractionDigits: 0,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const speedMps = layerState._satelliteTelemetry.get(
|
||||
layerState._selectedLaunchId,
|
||||
)?.speedMps;
|
||||
setMissionPanelField(
|
||||
'[data-mission-speed]',
|
||||
Number.isFinite(speedMps)
|
||||
speed: Number.isFinite(speedMps)
|
||||
? t('{value} KM/S', {
|
||||
value: (speedMps / 1000).toLocaleString(undefined, {
|
||||
value: formatNumber(speedMps / 1000, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}),
|
||||
})
|
||||
: null,
|
||||
Number.isFinite(speedMps)
|
||||
? `${(speedMps * 3.6).toLocaleString(undefined, { maximumFractionDigits: 0 })} km/h`
|
||||
: '',
|
||||
);
|
||||
speedTitle: Number.isFinite(speedMps)
|
||||
? `${formatNumber(speedMps * 3.6, { maximumFractionDigits: 0 })} km/h`
|
||||
: null,
|
||||
};
|
||||
if (republish) publishPanel();
|
||||
}
|
||||
|
||||
function selectMissionAt(index) {
|
||||
@@ -451,121 +395,94 @@ export function createPanel({ state: layerState, services, parts, source }) {
|
||||
}, 140);
|
||||
}
|
||||
|
||||
function createMissionPanel() {
|
||||
if (layerState._missionPanel || typeof document === 'undefined') return;
|
||||
const host =
|
||||
document.getElementById('space-mission-panel-host') ||
|
||||
document.getElementById('right-context-rail');
|
||||
if (!host) return;
|
||||
layerState._missionRoster = document.getElementById('space-mission-roster');
|
||||
if (layerState._missionRoster) {
|
||||
layerState._missionRosterPreviewOwnership =
|
||||
createMissionRosterPreviewOwnership({
|
||||
preview: scheduleMissionRosterPreview,
|
||||
clear: clearMissionRosterPreviewState,
|
||||
});
|
||||
layerState._missionRoster.onclick = (event) => {
|
||||
const button =
|
||||
event.target instanceof Element
|
||||
? event.target.closest('[data-mission-roster-index]')
|
||||
: null;
|
||||
if (!button) return;
|
||||
selectMissionAt(Number(button.dataset.missionRosterIndex));
|
||||
};
|
||||
}
|
||||
layerState._missionPanel = document.createElement('aside');
|
||||
layerState._missionPanel.id = 'space-mission-panel';
|
||||
layerState._missionPanel.className = 'context-space-mission-detail';
|
||||
layerState._missionPanel.setAttribute(
|
||||
'aria-label',
|
||||
t('Selected Space Mission'),
|
||||
/** Select the mission before or after the selected one. */
|
||||
function selectAdjacentMission(step) {
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) + step,
|
||||
);
|
||||
layerState._missionPanel.innerHTML = `<div class="space-mission-view-header"><span>${t('SELECTED SPACE MISSION')}</span><button type="button" data-mission-close title="${t('Show all missions')}" aria-label="${t('Deselect mission')}">×</button></div><div class="space-mission-detail"><strong data-mission-title>${t('MISSION')}</strong><span data-mission-field data-mission-provider></span><span data-mission-field>${t('STATUS · ')}<b data-mission-status></b></span><span data-mission-field>${t('LAUNCH SITE · ')}<b data-mission-site></b></span><span data-mission-field>${t('LAUNCH TIME · ')}<b data-mission-time></b></span><span data-mission-field>${t('ORBIT · ')}<b data-mission-orbit></b></span><span>${t('ASCENT PATH · ')}<b data-mission-ascent-source></b></span><span data-mission-field>${t('CURRENT DISTANCE FROM EARTH · ')}<b data-mission-distance></b></span><span data-mission-field>${t('SATELLITE SPEED · ')}<b data-mission-speed></b></span></div><section class="mission-data-section"><h4>${t('PAYLOAD')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('NAME')}</th><th>${t('TYPE')}</th><th>${t('DESTINATION')}</th></tr></thead><tbody data-mission-payloads></tbody></table></div></section><section class="mission-data-section" data-mission-stages-section><h4>${t('STAGE / RE-ENTRY / RECOVERY')}</h4><div class="mission-table-scroll"><table class="mission-data-table"><thead><tr><th>${t('STAGE')}</th><th>${t('STATUS')}</th><th>${t('FINAL POSITION')}</th></tr></thead><tbody data-mission-stages></tbody></table></div></section><div class="mission-replay-speed-control"><div class="mission-replay-speed-header"><label for="space-mission-replay-speed">${t('REPLAY SPEED')}</label><output class="gev-slider-value" for="space-mission-replay-speed" data-mission-replay-speed-output>1×</output></div><input id="space-mission-replay-speed" class="gev-quantitative-slider" type="range" min="0.25" max="4" step="0.25" value="1" data-mission-replay-speed aria-label="${t('Replay speed multiplier')}"><div class="mission-replay-speed-scale" aria-hidden="true"><span>0.25×</span><span>1×</span><span>4×</span></div></div><div class="mission-action-row"><button type="button" class="mission-focus-button" data-mission-focus>${t('FOCUS')}</button><button type="button" class="mission-replay-button" data-mission-replay aria-pressed="false">${t('REPLAY ASCENT')}</button></div><div class="space-mission-nav"><button type="button" class="mission-nav-button" data-mission-prev title="${t('Previous mission')}"><span aria-hidden="true">‹</span> ${t('PREV')}</button><span class="mission-nav-index" data-mission-index>—</span><button type="button" class="mission-nav-button" data-mission-next title="${t('Next mission')}">${t('NEXT')} <span aria-hidden="true">›</span></button></div><button type="button" class="panel-layer-toggle" data-mission-show-all>${t('SHOW ALL / DESELECT')}</button>`;
|
||||
layerState._missionPanel
|
||||
.querySelector('.mission-action-row')
|
||||
.insertAdjacentHTML(
|
||||
'beforeend',
|
||||
`<div class="mission-replay-transport" data-mission-replay-transport hidden>
|
||||
<button type="button" data-mission-replay-toggle title="${t('Pause replay')}" aria-label="${t('Pause replay')}">Ⅱ</button>
|
||||
<button type="button" class="cancel" data-mission-replay-cancel title="${t('Cancel replay')}" aria-label="${t('Cancel replay')}"><span aria-hidden="true">×</span></button>
|
||||
</div>`,
|
||||
);
|
||||
host.appendChild(layerState._missionPanel);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-prev]')
|
||||
.addEventListener('click', () =>
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) - 1,
|
||||
),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-next]')
|
||||
.addEventListener('click', () =>
|
||||
selectMissionAt(
|
||||
layerState._launches.findIndex(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
) + 1,
|
||||
),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-close]')
|
||||
.addEventListener('click', () =>
|
||||
parts.selection.setSelectedMission(null),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-show-all]')
|
||||
.addEventListener('click', () =>
|
||||
parts.selection.setSelectedMission(null),
|
||||
);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-focus]')
|
||||
.addEventListener('click', () => {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
if (launch) parts.selection.focusLaunchSite(launch);
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay]')
|
||||
.addEventListener('click', () => {
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.replay.startMissionReplay(layerState._selectedLaunchId);
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-toggle]')
|
||||
.addEventListener('click', () => {
|
||||
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
|
||||
else parts.replay.pauseMissionReplay();
|
||||
});
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-cancel]')
|
||||
.addEventListener('click', parts.replay.stopMissionReplay);
|
||||
layerState._missionPanel
|
||||
.querySelector('[data-mission-replay-speed]')
|
||||
.addEventListener('input', (event) => {
|
||||
parts.replay.setReplaySpeed(event.currentTarget.value);
|
||||
});
|
||||
parts.replay.syncReplaySpeedControl();
|
||||
}
|
||||
|
||||
/** The panel's commands, called by Core's mission components. */
|
||||
const commands = {
|
||||
selectMissionAt,
|
||||
selectPreviousMission: () => selectAdjacentMission(-1),
|
||||
selectNextMission: () => selectAdjacentMission(1),
|
||||
deselectMission: () => parts.selection.setSelectedMission(null),
|
||||
focusSelectedLaunchSite() {
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
if (launch) parts.selection.focusLaunchSite(launch);
|
||||
},
|
||||
startSelectedReplay() {
|
||||
if (layerState._selectedLaunchId)
|
||||
parts.replay.startMissionReplay(layerState._selectedLaunchId);
|
||||
},
|
||||
toggleReplayPause() {
|
||||
if (layerState._replayPaused) parts.replay.resumeMissionReplay();
|
||||
else parts.replay.pauseMissionReplay();
|
||||
},
|
||||
cancelReplay: () => parts.replay.stopMissionReplay(),
|
||||
setReplaySpeed: (value) => parts.replay.setReplaySpeed(value),
|
||||
rosterPointerEnter: (index) =>
|
||||
layerState._missionRosterPreviewOwnership?.pointerEnter(index),
|
||||
rosterPointerLeave: () =>
|
||||
layerState._missionRosterPreviewOwnership?.pointerLeave(),
|
||||
rosterFocus: (index) =>
|
||||
layerState._missionRosterPreviewOwnership?.focus(index),
|
||||
rosterBlur: (nextIndex) =>
|
||||
layerState._missionRosterPreviewOwnership?.blur(nextIndex),
|
||||
};
|
||||
|
||||
/** Start presenting the roster and the selected mission. */
|
||||
function createMissionPanel() {
|
||||
if (layerState._missionPanelReady) return;
|
||||
layerState._missionPanelReady = true;
|
||||
layerState._missionRosterPreviewOwnership =
|
||||
createMissionRosterPreviewOwnership({
|
||||
preview: scheduleMissionRosterPreview,
|
||||
clear: clearMissionRosterPreviewState,
|
||||
});
|
||||
// The roster and mission are worded again in a new locale.
|
||||
layerState._offMissionLocale = onLocaleChange(() => {
|
||||
renderMissionRoster();
|
||||
updateMissionTelemetry(true, false);
|
||||
publishPanel();
|
||||
});
|
||||
publishPanel();
|
||||
}
|
||||
|
||||
/** Stop presenting; the components return to their initial markup. */
|
||||
function clearMissionPanel() {
|
||||
layerState._missionPanelReady = false;
|
||||
layerState._offMissionLocale?.();
|
||||
layerState._offMissionLocale = null;
|
||||
layerState._missionRosterPreviewOwnership = null;
|
||||
layerState._missionRosterView = null;
|
||||
layerState._missionRosterSignature = null;
|
||||
layerState._missionTelemetry = null;
|
||||
layerState._replayTransportLabel = null;
|
||||
publish({ roster: null, mission: null });
|
||||
}
|
||||
|
||||
return {
|
||||
missionHoverPreviewRange,
|
||||
createMissionRosterPreviewOwnership,
|
||||
bindMissionRosterItemKeyboardPreview,
|
||||
captureMissionRosterFocus,
|
||||
restoreMissionRosterFocus,
|
||||
resolveMissionRosterPreviewLaunch,
|
||||
missionTableRows,
|
||||
setMissionPanelField,
|
||||
subscribePanel,
|
||||
publishPanel,
|
||||
presentMission,
|
||||
renderMissionPanel,
|
||||
renderMissionRoster,
|
||||
escapeMissionText,
|
||||
updateMissionTelemetry,
|
||||
selectMissionAt,
|
||||
clearMissionRosterPreviewState,
|
||||
clearMissionRosterHover,
|
||||
scheduleMissionRosterPreview,
|
||||
createMissionPanel,
|
||||
clearMissionPanel,
|
||||
commands,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// The launches layer presents its roster and selected mission as plain views
|
||||
// and publishes them to subscribers; Core's mission components render them.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
function fixture() {
|
||||
const state = createState({
|
||||
services: { overlays: {}, render: {} },
|
||||
});
|
||||
const calls = [];
|
||||
const parts = {
|
||||
overlays: {
|
||||
shortMissionLabel: (name) => name,
|
||||
syncMissionOverlayEntries: () => calls.push(['overlays']),
|
||||
},
|
||||
policyHelpers: {
|
||||
missionRosterEntries: (launches) =>
|
||||
launches.map((launch, index) => ({ launch, index })),
|
||||
missionPathPresentation: () => ({ orbit: 'LEO', ascent: 'ESTIMATED' }),
|
||||
previewMissionFromRoster: (launch) => calls.push(['preview', launch.id]),
|
||||
},
|
||||
model: {
|
||||
missionMarkerColor: () => ({ toCssColorString: () => 'rgb(1, 2, 3)' }),
|
||||
},
|
||||
selection: {
|
||||
setSelectedMission: (id, isolate) => calls.push(['select', id, isolate]),
|
||||
focusMission: (launch) => calls.push(['focus-mission', launch.id]),
|
||||
focusLaunchSite: (launch) => calls.push(['focus-site', launch.id]),
|
||||
},
|
||||
replay: {
|
||||
presentReplay: () => ({ available: false }),
|
||||
startMissionReplay: (id) => calls.push(['replay', id]),
|
||||
stopMissionReplay: () => calls.push(['cancel']),
|
||||
pauseMissionReplay: () => calls.push(['pause']),
|
||||
resumeMissionReplay: () => calls.push(['resume']),
|
||||
setReplaySpeed: (value) => calls.push(['speed', value]),
|
||||
},
|
||||
};
|
||||
const panel = createPanel({ state, parts });
|
||||
const views = [];
|
||||
panel.subscribePanel((view) => views.push(view));
|
||||
return { state, panel, views, calls };
|
||||
}
|
||||
|
||||
const launch = (id, extra = {}) => ({
|
||||
id,
|
||||
name: `Mission ${id}`,
|
||||
provider: 'SpaceX',
|
||||
status: 'Go',
|
||||
launchSite: 'Unknown launch site',
|
||||
launchTime: '2026-11-01T10:00:00Z',
|
||||
payloads: [],
|
||||
recoveryStages: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('the panel publishes only once the layer starts presenting', () => {
|
||||
const { state, panel, views } = fixture();
|
||||
state._launches = [launch('a')];
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(views.length, 0);
|
||||
panel.createMissionPanel();
|
||||
assert.deepEqual(views, [{ roster: null, mission: null }]);
|
||||
panel.renderMissionRoster();
|
||||
const { roster } = views.at(-1);
|
||||
assert.equal(roster.count, '1 / 30D');
|
||||
assert.deepEqual(roster.entries, [
|
||||
{
|
||||
index: 0,
|
||||
id: 'a',
|
||||
color: 'rgb(1, 2, 3)',
|
||||
label: 'MISSION A',
|
||||
ariaLabel: 'Select MISSION A',
|
||||
provider: 'SpaceX',
|
||||
date: '2026-11-01',
|
||||
},
|
||||
]);
|
||||
const late = [];
|
||||
panel.subscribePanel((view) => late.push(view));
|
||||
assert.equal(late[0], views.at(-1));
|
||||
panel.clearMissionPanel();
|
||||
assert.deepEqual(views.at(-1), { roster: null, mission: null });
|
||||
});
|
||||
|
||||
test('an unchanged roster keeps its view and the preview; a changed one drops it', async () => {
|
||||
const { state, panel, views, calls } = fixture();
|
||||
state._launches = [launch('a'), launch('b')];
|
||||
panel.createMissionPanel();
|
||||
panel.renderMissionRoster();
|
||||
const published = views.length;
|
||||
const roster = views.at(-1).roster;
|
||||
panel.commands.rosterFocus(1);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(views.length, published);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
await new Promise((resolve) => setTimeout(resolve, 160));
|
||||
assert.deepEqual(calls.at(-1), ['preview', 'b']);
|
||||
state._launches = [launch('b'), launch('a')];
|
||||
panel.renderMissionRoster();
|
||||
assert.equal(state._hoveredRosterLaunchId, null);
|
||||
assert.notEqual(views.at(-1).roster, roster);
|
||||
// The list restores focus on the mission, which takes the preview back.
|
||||
panel.commands.rosterFocus(0);
|
||||
assert.equal(state._hoveredRosterLaunchId, 'b');
|
||||
panel.clearMissionPanel();
|
||||
});
|
||||
|
||||
test('the selected mission is presented with its rows and returns to the top', () => {
|
||||
const { state, panel, views } = fixture();
|
||||
state._launches = [
|
||||
launch('a'),
|
||||
launch('b', {
|
||||
payloads: Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `Sat ${index}`,
|
||||
amount: index === 0 ? 2 : 1,
|
||||
manufacturer: 'Maker',
|
||||
massKg: 500,
|
||||
})),
|
||||
recoveryStages: [
|
||||
{
|
||||
name: 'B1080',
|
||||
status: 'Landed',
|
||||
flightNumber: 9,
|
||||
reused: true,
|
||||
endpoint: { lat: 28.4, lon: -77.1, accuracy: 'DRONESHIP' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
panel.createMissionPanel();
|
||||
state._selectedLaunchId = 'b';
|
||||
panel.renderMissionPanel();
|
||||
const first = views.at(-1).mission;
|
||||
assert.equal(first.title, 'MISSION B');
|
||||
assert.equal(first.site, null);
|
||||
assert.equal(first.orbit, 'LEO');
|
||||
assert.equal(first.index, '2 / 2');
|
||||
assert.equal(first.previousDisabled, false);
|
||||
assert.equal(first.nextDisabled, true);
|
||||
assert.equal(first.payloads.length, 5);
|
||||
assert.deepEqual(first.payloads[0], {
|
||||
name: 'Sat 0 ×2',
|
||||
detail: 'Maker · 500 KG',
|
||||
type: 'UNSPECIFIED',
|
||||
destination: 'UNAVAILABLE',
|
||||
});
|
||||
assert.equal(first.payloadsMore, '+1 additional payload records');
|
||||
assert.deepEqual(first.stages, [
|
||||
{
|
||||
name: 'B1080',
|
||||
detail: 'FLIGHT 9 · REUSED',
|
||||
status: 'Landed',
|
||||
destination: 'UNAVAILABLE',
|
||||
position: '28.40, -77.10 · DRONESHIP',
|
||||
},
|
||||
]);
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(views.at(-1).mission.scrollRequest, first.scrollRequest + 1);
|
||||
state._selectedLaunchId = null;
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(views.at(-1).mission, null);
|
||||
});
|
||||
|
||||
test('the panel commands act on the selected mission', () => {
|
||||
const { state, panel, calls } = fixture();
|
||||
state._launches = [launch('a'), launch('b'), launch('c')];
|
||||
state._selectedLaunchId = 'b';
|
||||
const { commands } = panel;
|
||||
commands.selectPreviousMission();
|
||||
commands.selectNextMission();
|
||||
commands.selectMissionAt(9);
|
||||
commands.focusSelectedLaunchSite();
|
||||
commands.startSelectedReplay();
|
||||
commands.toggleReplayPause();
|
||||
state._replayPaused = true;
|
||||
commands.toggleReplayPause();
|
||||
commands.cancelReplay();
|
||||
commands.setReplaySpeed('2');
|
||||
commands.deselectMission();
|
||||
assert.deepEqual(calls, [
|
||||
['select', 'a', true],
|
||||
['focus-mission', 'a'],
|
||||
['select', 'c', true],
|
||||
['focus-mission', 'c'],
|
||||
['focus-site', 'b'],
|
||||
['replay', 'b'],
|
||||
['pause'],
|
||||
['resume'],
|
||||
['cancel'],
|
||||
['speed', '2'],
|
||||
['select', null, undefined],
|
||||
]);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createPanel } from './panel.js';
|
||||
|
||||
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
|
||||
for (const cyber of [true, false]) {
|
||||
const body = { scrollTop: 180 };
|
||||
const inner = { scrollTop: 90 };
|
||||
const output = { closest: () => null, removeAttribute() {} };
|
||||
const state = {
|
||||
_missionPanel: {
|
||||
querySelector: () => output,
|
||||
closest(selector) {
|
||||
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
|
||||
return cyber ? body : inner;
|
||||
},
|
||||
},
|
||||
_selectedLaunchId: 'mission-1',
|
||||
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
|
||||
_replayTracks: new Map(),
|
||||
};
|
||||
const panel = createPanel({
|
||||
state,
|
||||
parts: {
|
||||
overlays: { shortMissionLabel: (name) => name },
|
||||
policyHelpers: { missionPathPresentation: () => ({}) },
|
||||
replay: { syncReplayButton() {} },
|
||||
},
|
||||
});
|
||||
panel.renderMissionPanel();
|
||||
assert.equal(body.scrollTop, cyber ? 0 : 180);
|
||||
assert.equal(inner.scrollTop, cyber ? 90 : 0);
|
||||
}
|
||||
});
|
||||
@@ -179,60 +179,41 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Republish the mission panel after a replay state change. */
|
||||
function syncReplayButton() {
|
||||
const button = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay]',
|
||||
);
|
||||
const transport = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-transport]',
|
||||
);
|
||||
const speedControl = layerState._missionPanel?.querySelector(
|
||||
'.mission-replay-speed-control',
|
||||
);
|
||||
if (!button) return;
|
||||
parts.panel.publishPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* The replay controls as the mission panel shows them: the start button,
|
||||
* the transport with its pause toggle, and the speed slider.
|
||||
*/
|
||||
function presentReplay() {
|
||||
const active = Boolean(
|
||||
layerState._replayCameraLaunchId &&
|
||||
layerState._replayCameraLaunchId === layerState._selectedLaunchId,
|
||||
);
|
||||
const replayAvailable = Boolean(
|
||||
const available = Boolean(
|
||||
layerState._selectedLaunchId &&
|
||||
layerState._replayTracks.has(layerState._selectedLaunchId),
|
||||
);
|
||||
if (speedControl) speedControl.hidden = !replayAvailable;
|
||||
button.hidden = active || !replayAvailable;
|
||||
button.disabled = !replayAvailable;
|
||||
button.textContent = t('REPLAY ASCENT');
|
||||
button.classList.remove('active');
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.title = t('Replay the estimated ascent with a following camera');
|
||||
if (transport) {
|
||||
transport.hidden = !active;
|
||||
transport.classList.toggle(
|
||||
'is-paused',
|
||||
active && layerState._replayPaused,
|
||||
);
|
||||
const toggleButton = transport.querySelector(
|
||||
'[data-mission-replay-toggle]',
|
||||
);
|
||||
if (toggleButton) {
|
||||
toggleButton.disabled = !active;
|
||||
toggleButton.textContent = layerState._replayPaused ? '▶' : 'Ⅱ';
|
||||
toggleButton.setAttribute(
|
||||
'aria-label',
|
||||
layerState._replayPaused ? t('Resume replay') : t('Pause replay'),
|
||||
);
|
||||
toggleButton.title = layerState._replayPaused
|
||||
? t('Resume replay')
|
||||
: t('Pause replay');
|
||||
}
|
||||
}
|
||||
const paused = layerState._replayPaused;
|
||||
const speed = layerState._replaySpeed;
|
||||
return {
|
||||
available,
|
||||
active,
|
||||
paused: active && paused,
|
||||
toggleText: paused ? '▶' : 'Ⅱ',
|
||||
toggleLabel: paused ? t('Resume replay') : t('Pause replay'),
|
||||
transportLabel: layerState._replayTransportLabel,
|
||||
speed: String(speed),
|
||||
speedText: `${speed.toFixed(speed % 1 ? 2 : 0)}×`,
|
||||
speedProgress: `${((speed - REPLAY_SPEED_MIN) / (REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) * 100}%`,
|
||||
};
|
||||
}
|
||||
|
||||
function syncReplayCountdownButton(state) {
|
||||
const transport = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-transport]',
|
||||
);
|
||||
if (!transport || !layerState._replayCameraLaunchId) return;
|
||||
if (!layerState._replayCameraLaunchId) return;
|
||||
const phase = state.countdownActive
|
||||
? t('T minus {seconds}', {
|
||||
seconds: state.countdownSeconds,
|
||||
@@ -244,29 +225,14 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
: state.ascending
|
||||
? t('Ascent replay')
|
||||
: t('Orbit replay');
|
||||
transport.setAttribute(
|
||||
'aria-label',
|
||||
`${phase}${layerState._replayPaused ? t(', paused') : ''}`,
|
||||
);
|
||||
const label = `${phase}${layerState._replayPaused ? t(', paused') : ''}`;
|
||||
if (label === layerState._replayTransportLabel) return;
|
||||
layerState._replayTransportLabel = label;
|
||||
parts.panel.publishPanel();
|
||||
}
|
||||
|
||||
function syncReplaySpeedControl() {
|
||||
const input = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-speed]',
|
||||
);
|
||||
const output = layerState._missionPanel?.querySelector(
|
||||
'[data-mission-replay-speed-output]',
|
||||
);
|
||||
if (input) {
|
||||
input.value = String(layerState._replaySpeed);
|
||||
const progress =
|
||||
((layerState._replaySpeed - REPLAY_SPEED_MIN) /
|
||||
(REPLAY_SPEED_MAX - REPLAY_SPEED_MIN)) *
|
||||
100;
|
||||
input.style.setProperty('--replay-speed-progress', `${progress}%`);
|
||||
}
|
||||
if (output)
|
||||
output.textContent = `${layerState._replaySpeed.toFixed(layerState._replaySpeed % 1 ? 2 : 0)}×`;
|
||||
parts.panel.publishPanel();
|
||||
}
|
||||
|
||||
function setReplaySpeed(value) {
|
||||
@@ -548,6 +514,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
replayStartAfterPause,
|
||||
replayState,
|
||||
syncReplayButton,
|
||||
presentReplay,
|
||||
syncReplayCountdownButton,
|
||||
syncReplaySpeedControl,
|
||||
setReplaySpeed,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// src/layers/launches/replayOverlay.test.mjs — the launches layer presents
|
||||
// its replay vehicle overlay to subscribers, and the scene chrome renders it
|
||||
// inside the globe container.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useMissionReplayStore } from '../../ui/stores/missionReplay.js';
|
||||
import SceneChromeView from '../../ui/views/SceneChromeView.vue';
|
||||
import { createOverlays } from './overlays.js';
|
||||
|
||||
function overlays() {
|
||||
const state = {
|
||||
_replayVehicleOverlay: null,
|
||||
_replayVehicleOverlayListeners: new Set(),
|
||||
};
|
||||
const parts = createOverlays({ state, services: {}, parts: {}, source: {} });
|
||||
return Object.assign(parts, { state });
|
||||
}
|
||||
|
||||
test('subscribers get the overlay as it is created, hidden and removed, never twice the same', () => {
|
||||
const parts = overlays();
|
||||
const views = [];
|
||||
const unsubscribe = parts.subscribeReplayOverlay((view) => views.push(view));
|
||||
assert.deepEqual(views, [null], 'a subscriber starts from the current one');
|
||||
parts.createReplayVehicleOverlay();
|
||||
parts.createReplayVehicleOverlay();
|
||||
assert.equal(views.length, 2);
|
||||
assert.equal(views[1].visible, false);
|
||||
parts.hideReplayVehicleOverlay();
|
||||
assert.equal(views.length, 2, 'an unchanged overlay is not sent again');
|
||||
parts.destroyReplayVehicleOverlay();
|
||||
assert.equal(views.at(-1), null);
|
||||
parts.hideReplayVehicleOverlay();
|
||||
unsubscribe();
|
||||
parts.createReplayVehicleOverlay();
|
||||
assert.equal(views.length, 3);
|
||||
});
|
||||
|
||||
test('the scene chrome renders the overlay in the globe container', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const app = createApp(SceneChromeView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const store = useMissionReplayStore(pinia);
|
||||
const node = () => document.querySelector('.mission-replay-vehicle-overlay');
|
||||
const parts = overlays();
|
||||
parts.subscribeReplayOverlay((view) => store.publish(view));
|
||||
await nextTick();
|
||||
assert.equal(node(), null);
|
||||
parts.createReplayVehicleOverlay();
|
||||
await nextTick();
|
||||
const element = node();
|
||||
assert.equal(element.parentElement.id, 'cesiumContainer');
|
||||
assert.equal(element.hidden, true);
|
||||
assert.equal(element.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(element.hasAttribute('data-mode'), false);
|
||||
assert.equal(
|
||||
element.querySelectorAll('.mission-replay-thrust ellipse').length,
|
||||
6,
|
||||
);
|
||||
// A frame as updateReplayVehicleOverlay presents it.
|
||||
parts.state._replayVehicleOverlay = {
|
||||
...parts.state._replayVehicleOverlay,
|
||||
visible: true,
|
||||
mode: 'ascent',
|
||||
thrusting: true,
|
||||
paused: false,
|
||||
transform: 'translate3d(10px, 20px, 0) translate(-50%, -50%)',
|
||||
rotation: '12deg',
|
||||
iconOpacity: '0.5',
|
||||
labelOpacity: '1',
|
||||
title: 'ASCENT REPLAY · TEST',
|
||||
detail: 'T+00:10',
|
||||
};
|
||||
store.publish(parts.state._replayVehicleOverlay);
|
||||
await nextTick();
|
||||
assert.equal(element.hidden, false);
|
||||
assert.equal(element.dataset.mode, 'ascent');
|
||||
assert.ok(element.classList.contains('is-thrusting'));
|
||||
assert.equal(element.classList.contains('is-paused'), false);
|
||||
assert.equal(
|
||||
element.style.getPropertyValue('--mission-replay-rotation'),
|
||||
'12deg',
|
||||
);
|
||||
assert.match(element.style.transform, /translate3d\(10px, 20px, 0(px)?\)/);
|
||||
assert.equal(
|
||||
element.querySelector('.mission-replay-flight-symbol').style.opacity,
|
||||
'0.5',
|
||||
);
|
||||
assert.equal(
|
||||
element.querySelector('.mission-replay-orbit-dot').style.opacity,
|
||||
'0.5',
|
||||
);
|
||||
assert.equal(
|
||||
element.querySelector('.mission-replay-overlay-callout').style.opacity,
|
||||
'1',
|
||||
);
|
||||
assert.equal(
|
||||
element.querySelector('[data-replay-overlay-title]').textContent,
|
||||
'ASCENT REPLAY · TEST',
|
||||
);
|
||||
assert.equal(
|
||||
element.querySelector('[data-replay-overlay-detail]').textContent,
|
||||
'T+00:10',
|
||||
);
|
||||
parts.hideReplayVehicleOverlay();
|
||||
await nextTick();
|
||||
assert.equal(element.hidden, true);
|
||||
assert.equal(element.classList.contains('is-thrusting'), false);
|
||||
assert.equal(
|
||||
element.dataset.mode,
|
||||
'ascent',
|
||||
'hiding keeps the mode, as before',
|
||||
);
|
||||
parts.destroyReplayVehicleOverlay();
|
||||
await nextTick();
|
||||
assert.equal(node(), null);
|
||||
});
|
||||
@@ -53,9 +53,21 @@ export function createState({ services }) {
|
||||
|
||||
state._launches = [];
|
||||
|
||||
state._missionPanel = null;
|
||||
state._missionPanelReady = false;
|
||||
|
||||
state._missionRoster = null;
|
||||
state._missionPanelView = null;
|
||||
|
||||
state._missionPanelSignature = null;
|
||||
|
||||
state._missionPanelListeners = new Set();
|
||||
|
||||
state._missionRosterView = null;
|
||||
|
||||
state._missionRosterSignature = null;
|
||||
|
||||
state._missionTelemetry = null;
|
||||
|
||||
state._missionPanelScrollRequest = 0;
|
||||
|
||||
state._missionRosterHoverTimer = null;
|
||||
|
||||
@@ -65,9 +77,10 @@ export function createState({ services }) {
|
||||
|
||||
state._missionHoverReticleImage = null;
|
||||
|
||||
// The replay vehicle overlay as its view renders it; null without one.
|
||||
state._replayVehicleOverlay = null;
|
||||
|
||||
state._replayVehicleOverlayText = '';
|
||||
state._replayVehicleOverlayListeners = new Set();
|
||||
|
||||
state._animationStarts = new Map();
|
||||
|
||||
@@ -99,6 +112,8 @@ export function createState({ services }) {
|
||||
|
||||
state._replayPaused = false;
|
||||
|
||||
state._replayTransportLabel = null;
|
||||
|
||||
state._replayPausedAtMs = null;
|
||||
|
||||
state._replaySpeed = 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
|
||||
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
|
||||
altitude: grouped(altitude),
|
||||
speed: speed === null ? DASH : speed,
|
||||
track: track === null ? DASH : `${track % 360}°`,
|
||||
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
|
||||
}),
|
||||
vertical === null
|
||||
? t('V/S {rate}', { rate: DASH })
|
||||
|
||||
@@ -56,10 +56,12 @@ const DEFAULT_DISPLAY = Object.freeze({
|
||||
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
|
||||
*
|
||||
* Two inputs feed it. The receiver is any object with `getState()` returning
|
||||
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
|
||||
* `{ connected, status, message, errorText, mode, webUsbSupported, aircraft, messagesPerSecond }`,
|
||||
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
|
||||
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
|
||||
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
|
||||
* `src/sources/adsbRecords.js`, and the optional `errorText` is the English
|
||||
* text of an `error` status (`message`, the UI-language one, stands in for a
|
||||
* receiver without it). The browser WebUSB session is one such receiver.
|
||||
* The optional `feeds` session (`./feeds.js`) supplies records read
|
||||
* by the server from local 1090/978 MHz decoders; it polls only while this
|
||||
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
|
||||
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
|
||||
|
||||
@@ -51,7 +51,7 @@ function feedsConfigured(feedState) {
|
||||
|
||||
function webUsbStatus(receiver, heard, feedState) {
|
||||
if (receiver.status === 'error')
|
||||
return { status: 'error', error: receiver.message };
|
||||
return { status: 'error', error: receiver.errorText ?? receiver.message };
|
||||
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||
return { loading: true, loadingLabel: msg('opening receiver') };
|
||||
if (
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// "USB error", feed problems, even as the whole error) read in the UI language.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setLocale } from '../../i18n/index.js';
|
||||
import { setLocale, t } from '../../i18n/index.js';
|
||||
import { SdrController } from '../../sdr/controller.js';
|
||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||
import { localAdsbStatus } from './status.js';
|
||||
|
||||
@@ -66,3 +67,45 @@ for (const locale of ['es', 'ru']) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A receiver error is English data for the layer (stats, and what the voice
|
||||
// tools and the analyst read); the Radio card and the row show it translated.
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: a WebUSB receiver error stays English in stats and reads translated`, async () => {
|
||||
const receiver = new SdrController({
|
||||
storage: null,
|
||||
webUsb: {},
|
||||
providerFactory: () => ({
|
||||
get: async () => {
|
||||
throw new Error('No device was selected.');
|
||||
},
|
||||
}),
|
||||
});
|
||||
setLocale(locale);
|
||||
try {
|
||||
assert.equal(await receiver.connect('adsb'), false);
|
||||
const state = receiver.getState();
|
||||
assert.equal(state.status, 'error');
|
||||
assert.equal(state.errorText, 'No RTL-SDR device selected');
|
||||
assert.equal(state.message, t('No RTL-SDR device selected'));
|
||||
assert.notEqual(state.message, state.errorText);
|
||||
|
||||
const status = localAdsbStatus({
|
||||
receiver: state,
|
||||
feedState: null,
|
||||
heard: 0,
|
||||
});
|
||||
assert.equal(status.error, 'No RTL-SDR device selected');
|
||||
const row = rowFor(status, 0);
|
||||
assert.ok(row.includes(state.message), row);
|
||||
assert.doesNotMatch(row, /No RTL-SDR device selected/, row);
|
||||
|
||||
// The English text goes with the error: stop() clears it.
|
||||
await receiver.stop();
|
||||
assert.equal(receiver.getState().errorText, null);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
await receiver.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createMilitaryFlightLayer } from './index.js';
|
||||
import { createFlightState } from './state.js';
|
||||
import { createTracking } from './tracking.js';
|
||||
|
||||
function services() {
|
||||
const names = [
|
||||
@@ -211,3 +212,27 @@ test('a newly seen military contact appears at its delayed position, not ahead o
|
||||
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
|
||||
);
|
||||
});
|
||||
|
||||
test('voice context reads a track that rounds to north as 0°, not 360°', () => {
|
||||
const stubs = services();
|
||||
stubs.aircraftPresentation = { tr3bTypeLabel: (id, type) => type };
|
||||
const tracking = createTracking({
|
||||
flightState: {
|
||||
feed: { _lastSource: 'Fixture aircraft' },
|
||||
records: { data: new Map() },
|
||||
},
|
||||
services: stubs,
|
||||
parts: {
|
||||
queries: {
|
||||
_describeFlight: () => ({ track: 359.6, callsign: 'RCH123' }),
|
||||
_toCleanText: (value) => value || '',
|
||||
_formatAltitude: () => '',
|
||||
},
|
||||
},
|
||||
layer: {},
|
||||
});
|
||||
assert.equal(
|
||||
tracking._contextSubjectMetadata('ae1234').properties.heading,
|
||||
'0°',
|
||||
);
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
AMBER_TRANSPARENT,
|
||||
TRACKED_ICON_COLOR,
|
||||
} from './policy.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createTracking({
|
||||
flightState,
|
||||
@@ -120,7 +121,7 @@ export function createTracking({
|
||||
? `${Math.round(described.velocityMps * 1.944)} kt`
|
||||
: '',
|
||||
heading: Number.isFinite(described.track)
|
||||
? `${Math.round(described.track)}°`
|
||||
? `${roundHeadingDegrees(described.track)}°`
|
||||
: '',
|
||||
icao24,
|
||||
// Honesty cue: the contact is coasting on dead reckoning, so the
|
||||
|
||||
@@ -55,6 +55,8 @@ export function createFirePerimetersLayer({
|
||||
let _dataSource = null;
|
||||
let _count = 0;
|
||||
let _lastUpdate = null;
|
||||
// The proxy's last snapshot, served while WFIGS is unreachable.
|
||||
let _stale = false;
|
||||
let _lastError = null;
|
||||
let _enabled = false;
|
||||
let _clickHandler = null;
|
||||
@@ -260,6 +262,7 @@ export function createFirePerimetersLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
_enabled = false;
|
||||
console.log('[Data:FirePerimeters] Initialized');
|
||||
},
|
||||
@@ -287,9 +290,21 @@ export function createFirePerimetersLayer({
|
||||
_request = request;
|
||||
refreshInciwebIndex(request.signal);
|
||||
try {
|
||||
const rows = await source.getSnapshot({ signal: request.signal });
|
||||
// The source contract is getSnapshot; a source that can also say how
|
||||
// fresh its rows are (the WFIGS proxy) offers getSnapshotDetail.
|
||||
const snapshot =
|
||||
typeof source.getSnapshotDetail === 'function'
|
||||
? await source.getSnapshotDetail({ signal: request.signal })
|
||||
: { rows: await source.getSnapshot({ signal: request.signal }) };
|
||||
if (request.signal.aborted || _request !== request || !_enabled)
|
||||
return false;
|
||||
const { rows } = snapshot;
|
||||
_stale = snapshot.stale === true;
|
||||
// A stale snapshot is as old as its fetch, not this poll.
|
||||
const updatedAt =
|
||||
_stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? snapshot.fetchedAt
|
||||
: Date.now();
|
||||
|
||||
// Incident facts may change independently of the polygon's source clock.
|
||||
const signature = JSON.stringify(
|
||||
@@ -299,7 +314,7 @@ export function createFirePerimetersLayer({
|
||||
);
|
||||
if (signature === _snapshotSignature) {
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_lastUpdate = Date.now();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
return true;
|
||||
}
|
||||
@@ -355,7 +370,7 @@ export function createFirePerimetersLayer({
|
||||
// Refresh (or drop) the selected incident's card against the new feed.
|
||||
if (_selectedId) publishSelectedCard();
|
||||
_count = rows.length;
|
||||
_lastUpdate = Date.now();
|
||||
_lastUpdate = updatedAt;
|
||||
_lastError = null;
|
||||
console.log(
|
||||
`[Data:FirePerimeters] Updated: ${_count} incidents, ${nextEntities.length} polygons`,
|
||||
@@ -366,6 +381,8 @@ export function createFirePerimetersLayer({
|
||||
return false;
|
||||
console.warn('[Data:FirePerimeters] Fetch error:', e);
|
||||
_lastError = e?.message || msg('Perimeter source unavailable');
|
||||
// A failure now outranks the stale snapshot still drawn.
|
||||
_stale = false;
|
||||
return false;
|
||||
} finally {
|
||||
if (_request === request) _request = null;
|
||||
@@ -391,6 +408,7 @@ export function createFirePerimetersLayer({
|
||||
_count = 0;
|
||||
_lastUpdate = null;
|
||||
_lastError = null;
|
||||
_stale = false;
|
||||
},
|
||||
|
||||
/** Snapshot incident facts (with card-anchor coordinates) for the analyst query engine. */
|
||||
@@ -443,6 +461,7 @@ export function createFirePerimetersLayer({
|
||||
count: _count,
|
||||
lastUpdate: _lastUpdate,
|
||||
error: _lastError,
|
||||
stale: _stale,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -552,3 +552,36 @@ for (const action of ['disable', 'destroy', 'selection']) {
|
||||
if (action !== 'destroy') h.layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clears it', async () => {
|
||||
const fetchedAt = Date.now() - 3 * 60 * 60 * 1000;
|
||||
let snapshot = { rows: [row], stale: true, fetchedAt };
|
||||
const h = harness({
|
||||
getSnapshot: async () => snapshot.rows,
|
||||
getSnapshotDetail: async () => snapshot,
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
||||
});
|
||||
|
||||
test('a failed refresh after a stale snapshot reads as the failure, not stale', async () => {
|
||||
let fail = false;
|
||||
const h = harness({
|
||||
getSnapshot: async () => [row],
|
||||
getSnapshotDetail: async () => {
|
||||
if (fail) throw new Error('WFIGS HTTP 502');
|
||||
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
|
||||
},
|
||||
});
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, true);
|
||||
fail = true;
|
||||
await h.layer.update(h.viewer);
|
||||
assert.equal(h.layer.getStats().stale, false);
|
||||
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
|
||||
});
|
||||
@@ -8,20 +8,35 @@ const msg = (text) => text;
|
||||
export function createWfigsPerimeterSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/**
|
||||
* The rows with how fresh they are: the proxy answers from its last
|
||||
* snapshot, marked stale, while WFIGS is unreachable.
|
||||
* @returns {Promise<{rows: object[], stale: boolean, fetchedAt: number|null}>}
|
||||
*/
|
||||
async function getSnapshotDetail({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error(msg('Malformed perimeter snapshot'));
|
||||
return {
|
||||
rows: payload.rows,
|
||||
stale: payload.stale === true,
|
||||
fetchedAt: Number.isFinite(payload.fetchedAt) ? payload.fetchedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async getSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/fire-perimeters', { signal });
|
||||
if (!response.ok) throw new Error(`WFIGS HTTP ${response.status}`);
|
||||
const payload = await readResponseJsonCapped(
|
||||
response,
|
||||
80 * 1024 * 1024,
|
||||
signal,
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload?.rows))
|
||||
throw new Error(msg('Malformed perimeter snapshot'));
|
||||
return payload.rows;
|
||||
/** The perimeter rows (see getSnapshotDetail for staleness). */
|
||||
async getSnapshot(options = {}) {
|
||||
return (await getSnapshotDetail(options)).rows;
|
||||
},
|
||||
getSnapshotDetail,
|
||||
};
|
||||
}
|
||||
@@ -115,3 +115,67 @@ test('response-body completion honors cancellation without replacing records', a
|
||||
name: 'AbortError',
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed refresh serves the last snapshot marked stale for a day, then refuses', async () => {
|
||||
let clock = 1_000_000_000;
|
||||
let up = true;
|
||||
const plugin = firePerimetersProxy({
|
||||
now: () => clock,
|
||||
fetchImpl: async () => {
|
||||
if (!up) throw new Error('WFIGS unreachable');
|
||||
return Response.json(validPayload);
|
||||
},
|
||||
});
|
||||
let handler;
|
||||
plugin.configureServer({
|
||||
middlewares: { use: (_path, callback) => (handler = callback) },
|
||||
});
|
||||
const ask = async () => {
|
||||
const reply = {};
|
||||
await handler(
|
||||
{ url: '/', method: 'GET', socket: { remoteAddress: '127.0.0.1' } },
|
||||
{
|
||||
writeHead(status, headers) {
|
||||
reply.status = status;
|
||||
reply.staleHeader = headers?.['X-Data-Stale'] === 'true';
|
||||
},
|
||||
end(body) {
|
||||
reply.body = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
return reply;
|
||||
};
|
||||
const fresh = await ask();
|
||||
assert.equal(fresh.status, 200);
|
||||
assert.equal(fresh.body.stale, undefined);
|
||||
up = false;
|
||||
clock += 23 * 60 * 60 * 1000;
|
||||
const stale = await ask();
|
||||
assert.equal(stale.status, 200);
|
||||
assert.equal(stale.staleHeader, true);
|
||||
assert.equal(stale.body.stale, true);
|
||||
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
|
||||
clock += 2 * 60 * 60 * 1000;
|
||||
assert.equal((await ask()).status, 502, 'a day-old snapshot is not served');
|
||||
});
|
||||
|
||||
test('the snapshot detail carries the proxy staleness and fetch time', async () => {
|
||||
const fetchedAt = Date.UTC(2026, 9, 25, 12);
|
||||
const source = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json({ rows: [], stale: true, fetchedAt }),
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshotDetail(), {
|
||||
rows: [],
|
||||
stale: true,
|
||||
fetchedAt,
|
||||
});
|
||||
assert.deepEqual(await source.getSnapshot(), []);
|
||||
});
|
||||
|
||||
test('getSnapshot works when taken off its source object', async () => {
|
||||
const { getSnapshot } = createWfigsPerimeterSource({
|
||||
fetchImpl: async () => Response.json({ rows: [] }),
|
||||
});
|
||||
assert.deepEqual(await getSnapshot(), []);
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getLocale } from '../../i18n/index.js';
|
||||
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -10,9 +12,26 @@ export function createPresentation({
|
||||
return layerState._acceptedCatalogSnapshot;
|
||||
}
|
||||
|
||||
/** Build the station categories, labelled in the current locale. */
|
||||
|
||||
function buildCategories() {
|
||||
layerState._categories = Object.freeze(
|
||||
parts.categories
|
||||
.buildRadioCategories(layerState._stations)
|
||||
.map((category) => Object.freeze(category)),
|
||||
);
|
||||
layerState._categoriesLocale = getLocale();
|
||||
}
|
||||
|
||||
/** Return an immutable snapshot consumed by the right-rail UI. */
|
||||
|
||||
function getRadioUIState() {
|
||||
// Labels follow a language switch the next time the state is read.
|
||||
if (
|
||||
layerState._categories.length &&
|
||||
layerState._categoriesLocale !== getLocale()
|
||||
)
|
||||
buildCategories();
|
||||
const visible = parts.queries.visibleStations();
|
||||
const selected = parts.queries.selectedStation();
|
||||
return Object.freeze({
|
||||
@@ -98,6 +117,7 @@ export function createPresentation({
|
||||
return {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
buildCategories,
|
||||
emitState,
|
||||
subscribeToRadio,
|
||||
subscribeToRadioPlaybackControls,
|
||||
|
||||
@@ -295,11 +295,7 @@ export function createRendering({
|
||||
layerState._stationById = new Map(
|
||||
stations.map((station) => [station.id, station]),
|
||||
);
|
||||
layerState._categories = Object.freeze(
|
||||
parts.categories
|
||||
.buildRadioCategories(stations)
|
||||
.map((category) => Object.freeze(category)),
|
||||
);
|
||||
parts.presentation.buildCategories();
|
||||
if (
|
||||
layerState._filter === DEFAULT_RADIO_FILTER &&
|
||||
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createState({ services }) {
|
||||
layerState._stationById = new Map();
|
||||
|
||||
layerState._categories = [];
|
||||
layerState._categoriesLocale = null;
|
||||
|
||||
layerState._renderById = new Map();
|
||||
|
||||
|
||||
@@ -365,8 +365,9 @@ export function createStreetLevelLayer({
|
||||
/** The hint a ground-click provider shows, or the provider's own. */
|
||||
function hintFor(entry, stats) {
|
||||
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
|
||||
// No-break spaces keep a provider name (Google Street View) on one line.
|
||||
return t('zoom in to a street to open {name}', {
|
||||
name: translateKnown(entry.def.name),
|
||||
name: translateKnown(entry.def.name).replace(/ /g, '\u00a0'),
|
||||
});
|
||||
return stats.hint || '';
|
||||
}
|
||||
|
||||
Vendored
+15
@@ -310,6 +310,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
liveMode: layerState._liveMode,
|
||||
fetching: loading,
|
||||
flowError: layerState._flowError,
|
||||
stale: layerState._flowStale,
|
||||
coveragePct: flowCoveragePct,
|
||||
statusUnavailable: layerState._flowStatusUnavailable,
|
||||
roadSource: layerState._roadSource,
|
||||
@@ -321,6 +322,20 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
lastUpdate: layerState._lastUpdate,
|
||||
loading,
|
||||
mode: feed.mode,
|
||||
// Cached proxy tiles (TomTom budget spent or upstream failing): the
|
||||
// manager reads this as STALE. Any error (flow, roads, detail) reads
|
||||
// DEGRADED instead.
|
||||
stale: Boolean(
|
||||
layerState._liveMode &&
|
||||
layerState._flowStale &&
|
||||
!(
|
||||
layerState._roadError ||
|
||||
layerState._roadWarning ||
|
||||
layerState._detailError ||
|
||||
feed.error
|
||||
),
|
||||
),
|
||||
oldestTileAt: layerState._flowOldestTileAt ?? null,
|
||||
error:
|
||||
layerState._roadError ||
|
||||
layerState._roadWarning ||
|
||||
|
||||
@@ -186,10 +186,19 @@ export function createFlow({ state: layerState, services, parts, source }) {
|
||||
return layerState._warmFlow;
|
||||
layerState._warmFlowGeneration = generation;
|
||||
const signal = layerState._activeFetchAbort?.signal;
|
||||
layerState._warmFlow = ensureFlowStatus(signal).then(() => {
|
||||
layerState._warmFlow = ensureFlowStatus(signal).then(async () => {
|
||||
if (!layerState._liveMode || generation !== layerState._loadGeneration)
|
||||
return [];
|
||||
return fetchFlowForBounds(clamped, { signal });
|
||||
if (typeof source.fetchFlowDetail !== 'function')
|
||||
return fetchFlowForBounds(clamped, { signal });
|
||||
// The proxy serves cached tiles once the TomTom budget is spent or the
|
||||
// upstream fails; carry that mark so getStats() reads STALE, not LIVE.
|
||||
const detail = await source.fetchFlowDetail(clamped, { signal });
|
||||
if (generation === layerState._loadGeneration) {
|
||||
layerState._flowStale = detail.stale === true;
|
||||
layerState._flowOldestTileAt = detail.oldestTileAt ?? null;
|
||||
}
|
||||
return detail.segments;
|
||||
});
|
||||
layerState._warmFlow.catch(() => {});
|
||||
return layerState._warmFlow;
|
||||
|
||||
@@ -42,6 +42,8 @@ export function createLifecycle({
|
||||
layerState._lastViewCenter = null;
|
||||
layerState._flowRoads = null;
|
||||
layerState._flowError = null;
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
if (TRAFFIC_TIMING_ENABLED) {
|
||||
layerState._trafficTimingCurrentAnchor = null;
|
||||
layerState._trafficTimingSequence = 0;
|
||||
@@ -159,6 +161,8 @@ export function createLifecycle({
|
||||
// A stale outage from the last session would misreport a fresh enable —
|
||||
// the next load re-derives feed health from real evidence.
|
||||
layerState._flowError = null;
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
|
||||
layerState._surfaceFrameRemover?.();
|
||||
layerState._surfaceFrameRemover = null;
|
||||
|
||||
@@ -229,6 +229,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
* @param {boolean} [input.liveMode] - `/api/tomtom/status` reported a key.
|
||||
* @param {boolean} [input.fetching] - A viewport load is in flight.
|
||||
* @param {string|null} [input.flowError] - `deriveTrafficFlowError` result, if any.
|
||||
* @param {boolean} [input.stale] - The flow came from cached proxy tiles.
|
||||
* @param {number} [input.coveragePct] - Share of shown dots on matched roads, 0–100.
|
||||
* @param {boolean} [input.statusUnavailable] - The status probe itself failed.
|
||||
* @param {string} [input.roadSource] - Name of the geometry being drawn.
|
||||
@@ -239,6 +240,7 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
liveMode = false,
|
||||
fetching = false,
|
||||
flowError = null,
|
||||
stale = false,
|
||||
coveragePct = 0,
|
||||
statusUnavailable = false,
|
||||
roadSource = 'OpenStreetMap',
|
||||
@@ -258,6 +260,16 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
});
|
||||
return { mode, error: degraded, loadingLabel: degraded };
|
||||
}
|
||||
// Cached proxy tiles (TomTom budget spent or upstream failing) are not live.
|
||||
if (liveMode && stale && !fetching) {
|
||||
return {
|
||||
mode,
|
||||
error: null,
|
||||
loadingLabel: t('STALE · Roads: {source} · Flow: cached TomTom tiles', {
|
||||
source: roadSource,
|
||||
}),
|
||||
};
|
||||
}
|
||||
// TomTom and Hybrid name their geometry; OpenStreetMap keeps its match copy.
|
||||
if (liveMode && roadSource === 'TomTom') {
|
||||
return {
|
||||
|
||||
@@ -283,6 +283,64 @@ test('flow application preserves direct values and leaves Hybrid fill simulated'
|
||||
assert.equal(state._flowPending, 0);
|
||||
});
|
||||
|
||||
test('stale proxy flow tiles reach the layer stats and never read LIVE', async () => {
|
||||
const { createFlow } = await import('./flow.js');
|
||||
const { createModel } = await import('./model.js');
|
||||
const { createControls } = await import('./controls.js');
|
||||
const { createState } = await import('./state.js');
|
||||
const { layerFeedState } = await import('../../data/feedState.js');
|
||||
let detail = {
|
||||
segments: [flow],
|
||||
partial: false,
|
||||
stale: true,
|
||||
oldestTileAt: 1000,
|
||||
};
|
||||
const source = {
|
||||
fetchFlowDetail: async () => detail,
|
||||
fetchFlowForBounds: async () => detail.segments,
|
||||
getFlowSessionStats: () => ({ tilesFetched: 1 }),
|
||||
};
|
||||
const state = createState({ services: {} });
|
||||
Object.assign(state, {
|
||||
_enabled: true,
|
||||
_liveMode: true,
|
||||
_loadGeneration: 1,
|
||||
_flowStatusPromise: Promise.resolve(),
|
||||
_bucketCounts: { free: 3, slow: 0, jam: 0, sim: 1 },
|
||||
});
|
||||
const parts = {};
|
||||
const context = { state, services: { credits: {} }, parts, source };
|
||||
parts.model = createModel(context);
|
||||
parts.flow = createFlow(context);
|
||||
const controls = createControls(context);
|
||||
|
||||
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 1);
|
||||
const stale = controls.methods.getStats();
|
||||
assert.equal(stale.mode, 'live');
|
||||
assert.equal(stale.stale, true);
|
||||
assert.equal(stale.oldestTileAt, 1000);
|
||||
assert.doesNotMatch(stale.loadingLabel, /LIVE/);
|
||||
assert.equal(layerFeedState(stale), 'stale');
|
||||
|
||||
// A road failure outranks cached flow: the row reads the failure.
|
||||
state._roadError = 'Road geometry unavailable';
|
||||
assert.equal(controls.methods.getStats().stale, false);
|
||||
state._roadError = null;
|
||||
|
||||
detail = {
|
||||
segments: [flow],
|
||||
partial: false,
|
||||
stale: false,
|
||||
oldestTileAt: 2000,
|
||||
};
|
||||
state._loadGeneration = 2;
|
||||
await parts.flow.applyFlowToRoads([{ coords: coordinates }], box, 2);
|
||||
const live = controls.methods.getStats();
|
||||
assert.equal(live.stale, false);
|
||||
assert.match(live.loadingLabel, /^LIVE · Roads: OpenStreetMap/);
|
||||
assert.equal(layerFeedState(live), 'nominal');
|
||||
});
|
||||
|
||||
test('status names the drawn road source for each mode', async () => {
|
||||
const { createModel } = await import('./model.js');
|
||||
const { trafficFeedPresentation: present } = createModel({
|
||||
|
||||
@@ -108,6 +108,16 @@ export function createState({ services }) {
|
||||
|
||||
layerState._flowError = null;
|
||||
|
||||
/**
|
||||
* True while the drawn flow came from cached proxy tiles (TomTom budget
|
||||
* spent or upstream failing); `_flowOldestTileAt` is the oldest tile's
|
||||
* fetch time in ms, or null.
|
||||
* @type {boolean}
|
||||
*/
|
||||
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
|
||||
/**
|
||||
* True when `/api/tomtom/status` itself could not be reached, so the layer is
|
||||
* simulating because it could not ask — not because the server said "no key".
|
||||
|
||||
@@ -183,6 +183,9 @@ export function createViewport({ state: layerState, services, parts, source }) {
|
||||
layerState._fetching = false;
|
||||
layerState._flowPending = 0;
|
||||
layerState._roadError = null;
|
||||
// Nothing is drawn up here, cached or not.
|
||||
layerState._flowStale = false;
|
||||
layerState._flowOldestTileAt = null;
|
||||
parts.animation.clearDots();
|
||||
layerState._lastBounds = null;
|
||||
layerState._lastViewCenter = null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
transitRouteLabel,
|
||||
} from '../../data/transitFeeds.js';
|
||||
import { displayMotion, playbackPosition } from './movement.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
|
||||
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
@@ -407,7 +408,9 @@ export function buildTransitSelectionCopy(
|
||||
);
|
||||
if (Number.isFinite(record.bearing))
|
||||
reportedMotion.push(
|
||||
t('reported hdg {heading}°', { heading: Math.round(record.bearing) }),
|
||||
t('reported hdg {heading}°', {
|
||||
heading: roundHeadingDegrees(record.bearing),
|
||||
}),
|
||||
);
|
||||
// The operator's own claim, kept as a REPORT rather than as the live state.
|
||||
// It describes the moment of the report, which the display may not have
|
||||
|
||||
@@ -43,6 +43,18 @@ test('the selection card calls a Toronto 501 a streetcar', () => {
|
||||
assert.doesNotMatch(copy.details.join(' | '), /Tram/);
|
||||
});
|
||||
|
||||
test('the selection card reads a bearing that rounds to north as hdg 0°', () => {
|
||||
const hdg = (bearing) =>
|
||||
buildTransitSelectionCopy(
|
||||
ttc(),
|
||||
{ id: '4400', routeId: '501', timestamp: 0, bearing },
|
||||
'tram',
|
||||
0,
|
||||
).details.find((line) => line.includes('hdg'));
|
||||
assert.equal(hdg(359.4), 'reported hdg 359°');
|
||||
assert.equal(hdg(359.6), 'reported hdg 0°');
|
||||
});
|
||||
|
||||
test('detection labels carry the operator vocabulary within the field width', () => {
|
||||
const klass = transitDetectionClass('tram', ttc());
|
||||
assert.equal(klass, 'STREETCAR TTC');
|
||||
|
||||
+19
-20
@@ -4,6 +4,7 @@ import {
|
||||
normalizeVesselType,
|
||||
} from '../../data/vesselLabels.js';
|
||||
import { formatNumber, t } from '../../i18n/index.js';
|
||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||
|
||||
export function createCards({
|
||||
vesselState,
|
||||
@@ -13,28 +14,23 @@ export function createCards({
|
||||
options,
|
||||
}) {
|
||||
function updateSelectedVesselHud(record) {
|
||||
const el = document.getElementById('hud-ais-vessel');
|
||||
if (!el) return;
|
||||
|
||||
// Pinned vessels missing from recent refreshes get a stale marker
|
||||
const stale = (record.missedRefreshes || 0) > 0;
|
||||
el.classList.add('active');
|
||||
el.textContent = [
|
||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||
t('{type} SPD: {speed} HDG: {heading}', {
|
||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
||||
speed: formatSpeed(record.speed),
|
||||
heading: formatHeading(record.heading ?? record.course),
|
||||
}),
|
||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
||||
].join('\n');
|
||||
services.readouts?.presentSelectedVessel({
|
||||
text: [
|
||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||
t('{type} SPD: {speed} HDG: {heading}', {
|
||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
||||
speed: formatSpeed(record.speed),
|
||||
heading: formatHeading(record.heading ?? record.course),
|
||||
}),
|
||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
||||
].join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
function resetSelectedVesselHud() {
|
||||
const el = document.getElementById('hud-ais-vessel');
|
||||
if (!el) return;
|
||||
el.classList.remove('active');
|
||||
el.textContent = t('AIS: --');
|
||||
services.readouts?.presentSelectedVessel(null);
|
||||
}
|
||||
|
||||
function trimHudValue(value, maxLength) {
|
||||
@@ -60,7 +56,8 @@ export function createCards({
|
||||
if (record.speed !== null && record.speed !== undefined)
|
||||
parts.push(formatSpeed(record.speed));
|
||||
const direction = record.heading ?? record.course;
|
||||
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
|
||||
if (Number.isFinite(direction))
|
||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
@@ -91,7 +88,9 @@ export function createCards({
|
||||
[
|
||||
vesselTypeShort(record) || t('VESSEL'),
|
||||
formatSpeed(record.speed),
|
||||
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
||||
Number.isFinite(direction)
|
||||
? `${roundHeadingDegrees(direction)}°`
|
||||
: '--°',
|
||||
].join(' · '),
|
||||
];
|
||||
const destination = String(record.destination || '').trim();
|
||||
@@ -181,7 +180,7 @@ export function createCards({
|
||||
|
||||
function formatHeading(heading) {
|
||||
return t('{heading}DEG', {
|
||||
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
|
||||
heading: roundHeadingDegrees(heading) ?? '--',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { state } = vesselState;
|
||||
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
const { releaseContinuousRender } = services.render;
|
||||
const { ensureGeoidReady } = services.geoid;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
@@ -125,6 +125,7 @@ export function createLifecycle({
|
||||
state.interactionHandlerFactory = null;
|
||||
state.interactionKeyTarget = null;
|
||||
state.preRenderRemover = null;
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
state.lastVisibilityUpdate = 0;
|
||||
state.lastFocusUpdate = 0;
|
||||
state.activeFocusCount = 0;
|
||||
@@ -164,7 +165,6 @@ export function createLifecycle({
|
||||
const wasEnabled = state.feed.enabled;
|
||||
state.feed.enabled = true;
|
||||
if (!wasEnabled) beginAisSession();
|
||||
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
|
||||
const activeViewer = viewer || state.viewer;
|
||||
components.rendering.ensureCollections(activeViewer);
|
||||
components.selection.installInteraction(activeViewer);
|
||||
@@ -208,6 +208,7 @@ export function createLifecycle({
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
components.rendering.setVisible(false);
|
||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||
@@ -227,6 +228,7 @@ export function createLifecycle({
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
components.rendering.cancelTrailingVisibilityPass();
|
||||
if (state.feed.abort) state.feed.abort.abort();
|
||||
unregisterPickOwner('ais-live-vessels');
|
||||
components.selection.clearVesselInspection();
|
||||
|
||||
@@ -29,7 +29,11 @@ function services() {
|
||||
forgetSpriteFocus: noop,
|
||||
},
|
||||
worldFocus: {},
|
||||
render: { releaseContinuousRender: noop },
|
||||
render: {
|
||||
holdContinuousRender: noop,
|
||||
releaseContinuousRender: noop,
|
||||
governorRequestRender: noop,
|
||||
},
|
||||
};
|
||||
}
|
||||
function setup(source, options = {}) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createVesselLayer } from './index.js';
|
||||
import { VISIBILITY_UPDATE_MS } from './policy.js';
|
||||
|
||||
// Vessels move only when a poll lands, so the layer must let the scene idle:
|
||||
// it asks for frames for forced passes and one trailing frame after the camera
|
||||
// stops, and never schedules a frame from a pass the camera did not cause.
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function setup() {
|
||||
let clock = 10_000;
|
||||
const requests = [];
|
||||
const holds = new Set();
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const layer = createVesselLayer({
|
||||
source: { getSnapshot: async () => ({ records: [] }) },
|
||||
services: {
|
||||
context: {
|
||||
clearSelectedEntityContextForLayer: noop,
|
||||
registerEntityContext: () => null,
|
||||
},
|
||||
trails: {
|
||||
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
|
||||
},
|
||||
labels: {},
|
||||
picking: { unregisterPickOwner: noop },
|
||||
overlay: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
geoid: {},
|
||||
sprites: {},
|
||||
focus: {
|
||||
focusNowMs: () => clock,
|
||||
getFocusTarget: () => null,
|
||||
focusPassIsNeeded: () => false,
|
||||
forgetSpriteFocus: noop,
|
||||
},
|
||||
worldFocus: {},
|
||||
render: {
|
||||
holdContinuousRender: (id) => holds.add(id),
|
||||
releaseContinuousRender: (id) => holds.delete(id),
|
||||
governorRequestRender: (reason) => requests.push(reason),
|
||||
},
|
||||
},
|
||||
});
|
||||
const camera = {
|
||||
positionWC: { x: 0, y: 0, z: 7_000_000 },
|
||||
heading: 0,
|
||||
pitch: -1.2,
|
||||
roll: 0,
|
||||
};
|
||||
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
|
||||
layer.testing._setAisRuntimeForTest({
|
||||
now: () => clock,
|
||||
setTimeout(fn, ms) {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, { fn, at: clock + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => timers.delete(id),
|
||||
});
|
||||
const advance = (ms) => {
|
||||
clock += ms;
|
||||
for (const [id, timer] of [...timers]) {
|
||||
if (timer.at > clock) continue;
|
||||
timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
};
|
||||
const frame = () => layer.testing._runVisibilityPassForTest();
|
||||
return { layer, camera, requests, holds, timers, advance, frame };
|
||||
}
|
||||
|
||||
test('a parked camera schedules no frames after the cadence pass', () => {
|
||||
const { requests, holds, timers, advance, frame } = setup();
|
||||
frame();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
advance(100);
|
||||
frame();
|
||||
}
|
||||
assert.deepEqual(requests, []);
|
||||
assert.equal(timers.size, 0);
|
||||
assert.equal(holds.has('ais-vessels'), false);
|
||||
});
|
||||
|
||||
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
|
||||
const { layer, camera, requests, timers, advance, frame } = setup();
|
||||
frame();
|
||||
advance(100);
|
||||
camera.heading = 0.5;
|
||||
frame();
|
||||
advance(100);
|
||||
frame();
|
||||
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
|
||||
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
|
||||
|
||||
advance(VISIBILITY_UPDATE_MS);
|
||||
assert.deepEqual(requests, ['ais-vessels-visibility']);
|
||||
// The trailing frame runs the regular pass on the pose the camera stopped
|
||||
// on. Its own frames must not schedule another one.
|
||||
frame();
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
advance(100);
|
||||
frame();
|
||||
}
|
||||
assert.deepEqual(requests, ['ais-vessels-visibility']);
|
||||
assert.equal(timers.size, 0);
|
||||
});
|
||||
|
||||
test('a forced pass requests its own frame', () => {
|
||||
const { layer, requests } = setup();
|
||||
layer.testing._runVisibilityPassForTest(true);
|
||||
assert.deepEqual(requests, ['ais-vessels-update']);
|
||||
});
|
||||
|
||||
test('destroy cancels a pending trailing frame', (t) => {
|
||||
const originalDocument = globalThis.document;
|
||||
globalThis.document = { getElementById: () => null };
|
||||
t.after(() => {
|
||||
if (originalDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = originalDocument;
|
||||
});
|
||||
const { layer, camera, requests, timers, advance, frame } = setup();
|
||||
frame();
|
||||
advance(100);
|
||||
camera.heading = 0.5;
|
||||
frame();
|
||||
assert.equal(timers.size, 1);
|
||||
layer.destroy();
|
||||
assert.equal(timers.size, 0);
|
||||
advance(VISIBILITY_UPDATE_MS);
|
||||
assert.deepEqual(requests, []);
|
||||
});
|
||||
@@ -75,7 +75,13 @@ export function createRendering({
|
||||
focusPassIsNeeded,
|
||||
advanceSpriteFocus,
|
||||
focusAlphaNeedsWrite,
|
||||
spriteFocusAtRest,
|
||||
} = services.focus;
|
||||
const {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} = services.render;
|
||||
|
||||
function renderRowLimit() {
|
||||
const configured = Number(options.maxRows);
|
||||
@@ -196,6 +202,14 @@ export function createRendering({
|
||||
return icon;
|
||||
}
|
||||
|
||||
// Vessels move only when a poll lands, so the layer does not hold the
|
||||
// continuous render loop. Its passes run in preRender, which idle mode fires
|
||||
// only for rendered frames: a forced pass (new data, selection) requests its
|
||||
// frame, a frame that moved the camera but fell inside the 800 ms cadence
|
||||
// leaves a trailing frame so the view the camera stopped on is always
|
||||
// evaluated, and the continuous hold is kept only while focus emphasis is
|
||||
// animating. The trailing frame is keyed to camera motion alone: the pass's
|
||||
// own label publication requests a frame, and scheduling on that would loop.
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
@@ -203,8 +217,27 @@ export function createRendering({
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleTrailingVisibilityPass(now) {
|
||||
if (state.trailingVisibilityTimer) return;
|
||||
const waitMs = Math.max(
|
||||
0,
|
||||
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
|
||||
);
|
||||
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
|
||||
state.trailingVisibilityTimer = null;
|
||||
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
|
||||
}, waitMs);
|
||||
}
|
||||
|
||||
function cancelTrailingVisibilityPass() {
|
||||
if (!state.trailingVisibilityTimer) return;
|
||||
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
|
||||
state.trailingVisibilityTimer = null;
|
||||
}
|
||||
|
||||
function updateVisibility(force = false) {
|
||||
if (!state.feed.enabled) return;
|
||||
if (force) governorRequestRender('ais-vessels-update');
|
||||
const now = focusNowMs(performance.now());
|
||||
const focusTarget = getFocusTarget();
|
||||
const regularPass =
|
||||
@@ -212,13 +245,30 @@ export function createRendering({
|
||||
const focusPass =
|
||||
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
|
||||
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
||||
if (
|
||||
!regularPass &&
|
||||
state.viewer?.camera &&
|
||||
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
|
||||
)
|
||||
scheduleTrailingVisibilityPass(now);
|
||||
if (!regularPass && !focusPass) return;
|
||||
if (regularPass) state.lastVisibilityUpdate = now;
|
||||
if (focusPass) state.lastFocusUpdate = now;
|
||||
if (!state.records.all.length) {
|
||||
// No records — flush any lingering card entries (vanished-feed case).
|
||||
if (regularPass) updateClusteredLabels([]);
|
||||
if (focusPass) state.activeFocusCount = 0;
|
||||
// Still record the pose this pass evaluated, or every later frame that
|
||||
// falls inside the cadence would schedule another trailing frame.
|
||||
if (regularPass) {
|
||||
if (state.viewer?.camera)
|
||||
vesselState._lastCamPoseSig = cameraPoseSignature(
|
||||
state.viewer.camera,
|
||||
);
|
||||
updateClusteredLabels([]);
|
||||
}
|
||||
if (focusPass) {
|
||||
state.activeFocusCount = 0;
|
||||
releaseContinuousRender('ais-vessels');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -272,6 +322,9 @@ export function createRendering({
|
||||
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||
});
|
||||
state.activeFocusCount = result.activeCount;
|
||||
if (result.activeCount > 0 || result.transitioning)
|
||||
holdContinuousRender('ais-vessels');
|
||||
else releaseContinuousRender('ais-vessels');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +355,16 @@ export function createRendering({
|
||||
const bb = visual?.billboard;
|
||||
const position = bb?.position || visual?.position;
|
||||
if (!bb || !position) continue;
|
||||
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
|
||||
// no pending focus state and full alpha would advance to exactly what it
|
||||
// already is, so skip it instead of paying its distance and state work
|
||||
// every 80 ms while anything is tracked.
|
||||
if (
|
||||
bb.show === false &&
|
||||
spriteFocusAtRest(bb) &&
|
||||
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
|
||||
)
|
||||
continue;
|
||||
const focus = advanceSpriteFocus(bb, {
|
||||
// Hidden/far-side sprites still finish any pending release so the active
|
||||
// count remains truthful and they cannot reappear with stale dim alpha.
|
||||
@@ -510,6 +573,7 @@ export function createRendering({
|
||||
shipIcon,
|
||||
installRuntime,
|
||||
updateVisibility,
|
||||
cancelTrailingVisibilityPass,
|
||||
applyVesselFocusDeemphasis,
|
||||
makeOccluder,
|
||||
isVisible,
|
||||
|
||||
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
|
||||
/** Test-only key target paired with interactionHandlerFactory. */
|
||||
interactionKeyTarget: null,
|
||||
preRenderRemover: null,
|
||||
/** Pending timer that renders one frame after a cadence-skipped pass. */
|
||||
trailingVisibilityTimer: null,
|
||||
lastVisibilityUpdate: 0,
|
||||
lastFocusUpdate: 0,
|
||||
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
|
||||
|
||||
@@ -151,6 +151,18 @@ export function createTesting({
|
||||
};
|
||||
}
|
||||
|
||||
/** Run one preRender visibility/focus pass. Test-only seam. */
|
||||
|
||||
function _runVisibilityPassForTest(force = false) {
|
||||
components.rendering.updateVisibility(force);
|
||||
}
|
||||
|
||||
/** True while a trailing visibility frame is scheduled. Test-only seam. */
|
||||
|
||||
function _hasTrailingVisibilityPassForTest() {
|
||||
return state.trailingVisibilityTimer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lifecycle ownership state without exposing the mutable state object.
|
||||
* Test-only seam.
|
||||
@@ -176,5 +188,7 @@ export function createTesting({
|
||||
_setAisRuntimeForTest,
|
||||
_getVesselFeedStateForTest,
|
||||
_getVesselStateForTest,
|
||||
_runVisibilityPassForTest,
|
||||
_hasTrailingVisibilityPassForTest,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* A weather source can describe its own products (credit, units, coverage,
|
||||
* legend) in the optional `display` object of a product manifest. Each field
|
||||
* is checked on its own: an absent or invalid field is dropped, and the layer
|
||||
* keeps its built-in text for it. Units and legend describe the same scale,
|
||||
* so they are kept only together. Accepted text is source data, shown as is
|
||||
* rather than translated.
|
||||
*/
|
||||
|
||||
const TEXT_LIMITS = Object.freeze({
|
||||
source: 60,
|
||||
credit: 200,
|
||||
units: 40,
|
||||
coverage: 48,
|
||||
info: 600,
|
||||
});
|
||||
const LEGEND_MIN = 2;
|
||||
const LEGEND_MAX = 16;
|
||||
const LEGEND_LABEL_MAX = 16;
|
||||
// Control and line-break characters, plus invisible or format characters:
|
||||
// soft hyphen, combining grapheme joiner, Arabic letter mark, Mongolian vowel
|
||||
// separator, zero-width, direction-mark, bidi embedding/override/isolate,
|
||||
// variation selector, byte-order and tag characters.
|
||||
const CONTROL =
|
||||
/[\u0000-\u001f\u007f-\u009f\u00ad\u034f\u061c\u180e\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufe00-\ufe0f\ufeff\u{e0000}-\u{e007f}]/u;
|
||||
// A number label must read as a plain decimal, as the legend formats it.
|
||||
const DECIMAL = /^-?\d+(?:\.\d+)?$/;
|
||||
// A label the legend formats as a number: a plain decimal, maybe with "+".
|
||||
const SCALE_NUMBER = /^-?(\d+(?:\.(\d+))?)\+?$/;
|
||||
const COLOR = /^#[0-9a-f]{6}$/i;
|
||||
const EMPTY = Object.freeze({});
|
||||
const cache = new WeakMap();
|
||||
|
||||
function text(value, max) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed && trimmed.length <= max && !CONTROL.test(trimmed)
|
||||
? trimmed
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a label the legend formats as a number reads back exactly as given:
|
||||
* within the safe integer range and with no digits lost to rounding, so
|
||||
* '9999999999999999' (shown as 10000000000000000) and '007' are refused.
|
||||
* Other text is shown as given.
|
||||
*/
|
||||
function exactScaleLabel(label) {
|
||||
const match = SCALE_NUMBER.exec(label);
|
||||
if (!match) return true;
|
||||
const magnitude = Number(match[1]);
|
||||
return (
|
||||
magnitude <= Number.MAX_SAFE_INTEGER &&
|
||||
magnitude.toFixed(match[2]?.length ?? 0) === match[1]
|
||||
);
|
||||
}
|
||||
|
||||
function number(value) {
|
||||
if (!Number.isFinite(value)) return undefined;
|
||||
const label = String(value);
|
||||
return DECIMAL.test(label) && label.length <= LEGEND_LABEL_MAX
|
||||
? label
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function legend(value) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length < LEGEND_MIN ||
|
||||
value.length > LEGEND_MAX
|
||||
)
|
||||
return undefined;
|
||||
const stops = [];
|
||||
for (const stop of value) {
|
||||
if (!stop || typeof stop !== 'object') return undefined;
|
||||
const label =
|
||||
typeof stop.label === 'number'
|
||||
? number(stop.label)
|
||||
: text(stop.label, LEGEND_LABEL_MAX);
|
||||
if (label === undefined || !exactScaleLabel(label)) return undefined;
|
||||
if (typeof stop.color !== 'string' || !COLOR.test(stop.color))
|
||||
return undefined;
|
||||
stops.push(Object.freeze([label, stop.color]));
|
||||
}
|
||||
return Object.freeze(stops);
|
||||
}
|
||||
|
||||
function extent(value) {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const { west, south, east, north } = value;
|
||||
if (
|
||||
![west, south, east, north].every(Number.isFinite) ||
|
||||
west < -180 ||
|
||||
west > 180 ||
|
||||
east < -180 ||
|
||||
east > 180 ||
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
south >= north ||
|
||||
// No width: one meridian, including 180 to -180 across the antimeridian.
|
||||
west === east ||
|
||||
west - east >= 360
|
||||
)
|
||||
return undefined;
|
||||
// West greater than east crosses the antimeridian.
|
||||
return Object.freeze({ west, south, east, north });
|
||||
}
|
||||
|
||||
/**
|
||||
* The valid display fields of a weather manifest; `{}` when it has none.
|
||||
* @param {object|null|undefined} manifest A validated weather snapshot.
|
||||
* @returns {{ source?: string, credit?: string, units?: string,
|
||||
* coverage?: string, info?: string,
|
||||
* legend?: ReadonlyArray<readonly [string, string]>,
|
||||
* extent?: { west: number, south: number, east: number, north: number } }}
|
||||
*/
|
||||
export function weatherDisplay(manifest) {
|
||||
const display = manifest?.display;
|
||||
if (!display || typeof display !== 'object' || Array.isArray(display))
|
||||
return EMPTY;
|
||||
const known = cache.get(manifest);
|
||||
if (known) return known;
|
||||
const result = {};
|
||||
for (const [key, max] of Object.entries(TEXT_LIMITS)) {
|
||||
const value = text(display[key], max);
|
||||
if (value !== undefined) result[key] = value;
|
||||
}
|
||||
const stops = legend(display.legend);
|
||||
if (stops && result.units !== undefined) result.legend = stops;
|
||||
else delete result.units;
|
||||
const box = extent(display.extent);
|
||||
if (box) result.extent = box;
|
||||
Object.freeze(result);
|
||||
cache.set(manifest, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether a longitude/latitude falls inside an extent, across the antimeridian too. */
|
||||
export function insideExtent({ west, south, east, north }, lon, lat) {
|
||||
if (lat < south || lat > north) return false;
|
||||
return west < east ? lon >= west && lon <= east : lon >= west || lon <= east;
|
||||
}
|
||||
|
||||
/** Plain text for a Cesium credit, which otherwise reads its text as HTML. */
|
||||
export function escapeCreditText(value) {
|
||||
return value.replace(
|
||||
/[&<>"']/g,
|
||||
(ch) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[
|
||||
ch
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { GeographicTilingScheme } from 'cesium';
|
||||
import { createWeatherLayer } from './index.js';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
|
||||
import { createShellCesium, createShellScene } from './shellFixture.mjs';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z'];
|
||||
const base = {
|
||||
schemaVersion: 1,
|
||||
times,
|
||||
latest: times[1],
|
||||
bounds: { west: -180, south: -60, east: 180, north: 80 },
|
||||
};
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
const quiet = () => ({
|
||||
matches: false,
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
|
||||
/**
|
||||
* A layer whose renderer shows every requested frame at once. `center` is the
|
||||
* map centre as [lon, lat]; `manifests` maps a product to its manifest, and
|
||||
* may be changed between updates.
|
||||
*/
|
||||
async function shownLayer(id, manifest, params, view = {}) {
|
||||
let time = null;
|
||||
const flights = [];
|
||||
const snapshots = [];
|
||||
const manifests = { [manifest.product]: manifest };
|
||||
const layer = createWeatherLayer({
|
||||
id,
|
||||
feed: { getSnapshot: async ({ product }) => manifests[product] },
|
||||
documentRef: quiet(),
|
||||
eventTarget: quiet(),
|
||||
matchMedia: () => quiet(),
|
||||
createRendering: () => ({
|
||||
rehome() {},
|
||||
setAlpha() {},
|
||||
setHidden() {},
|
||||
updateSnapshot(snapshot) {
|
||||
snapshots.push(snapshot);
|
||||
},
|
||||
async setFrame(_snapshot, selected) {
|
||||
time = selected;
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
time = null;
|
||||
},
|
||||
getDiagnostics: () => ({ time, loading: false, error: null }),
|
||||
}),
|
||||
});
|
||||
layer.init({
|
||||
camera: {
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
flyTo: ({ destination }) => flights.push(destination),
|
||||
pickEllipsoid: () =>
|
||||
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined,
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
});
|
||||
if (params) layer.setParams(params);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
await flush();
|
||||
layer.attachShellServices({ runNavigation: (navigate) => navigate() });
|
||||
return { layer, flights, manifests, snapshots };
|
||||
}
|
||||
|
||||
const PRODUCTS = [
|
||||
['weather-radar', 'radar', undefined],
|
||||
['weather-lightning', 'lightning', undefined],
|
||||
['weather-satellite', 'clouds-regional', undefined],
|
||||
['weather-satellite', 'clouds', { product: 'clouds' }],
|
||||
];
|
||||
const INVALID = {
|
||||
source: 42,
|
||||
credit: '',
|
||||
units: 'x'.repeat(41),
|
||||
coverage: 'line one\nline two',
|
||||
info: ['not', 'text'],
|
||||
legend: [
|
||||
{ label: '1', color: '#ffffff' },
|
||||
{ label: '2', color: 'red' },
|
||||
],
|
||||
extent: { west: 0, south: 10, east: 20, north: 5 },
|
||||
};
|
||||
|
||||
const SATELLITE_TITLE =
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.';
|
||||
const SATELLITE_CREDIT =
|
||||
'NOAA nowCOAST · NESDIS GOES / global satellite partners';
|
||||
// Today's built-in description of each product, written out rather than
|
||||
// derived from the code under test.
|
||||
const BUILT_IN = {
|
||||
radar: {
|
||||
units: 'dBZ',
|
||||
coverage: 'CONUS',
|
||||
legend: [
|
||||
['10', '#00ecec'],
|
||||
['20', '#0000f6'],
|
||||
['30', '#00c800'],
|
||||
['40', '#ffff00'],
|
||||
['50', '#ff9000'],
|
||||
['60', '#dc0000'],
|
||||
['70', '#ff00ff'],
|
||||
['80', '#05ede0'],
|
||||
].map(([label, color]) => ({
|
||||
label,
|
||||
color,
|
||||
blurb: `${label} dBZ radar reflectivity`,
|
||||
})),
|
||||
heading: 'RADAR REFLECTIVITY · dBZ',
|
||||
infoCoverage: 'Contiguous US · gaps ≠ no rain',
|
||||
infoTitle:
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
chip: 'View US radar',
|
||||
focus: [-180, -60, 180, 80],
|
||||
credit: 'NOAA nowCOAST · NWS/OAR MRMS',
|
||||
},
|
||||
lightning: {
|
||||
units: 'strikes/km²/min ×10³',
|
||||
coverage: 'Americas + Pacific',
|
||||
legend: [
|
||||
['0.1', '#FFFFCC'],
|
||||
['1', '#FFA400'],
|
||||
['5', '#FF4500'],
|
||||
['10', '#FF0000'],
|
||||
['50', '#FF00FF'],
|
||||
['100', '#4000C0'],
|
||||
['200', '#00C7FF'],
|
||||
['300+', '#00FF00'],
|
||||
].map(([label, color]) => ({
|
||||
label,
|
||||
color,
|
||||
blurb: `${label} strikes/km²/min ×10³ (15-minute density)`,
|
||||
})),
|
||||
heading: 'LIGHTNING DENSITY · 15 min accumulation',
|
||||
infoCoverage:
|
||||
'Americas + Pacific · not individual strikes\nColor: strikes/km²/min ×10³',
|
||||
infoTitle:
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
chip: 'View Americas & Pacific',
|
||||
focus: [110, -25, 0, 80],
|
||||
credit: 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360',
|
||||
},
|
||||
'clouds-regional': {
|
||||
units: '',
|
||||
coverage: 'North America',
|
||||
legend: [],
|
||||
heading: 'GOES INFRARED · ~5 min',
|
||||
infoCoverage: 'North America · infrared imagery',
|
||||
infoTitle: SATELLITE_TITLE,
|
||||
chip: 'View coverage',
|
||||
focus: [-180, -60, 180, 80],
|
||||
credit: SATELLITE_CREDIT,
|
||||
},
|
||||
clouds: {
|
||||
units: '',
|
||||
coverage: 'Global · 60°S–60°N',
|
||||
legend: [],
|
||||
heading: 'GLOBAL INFRARED · hourly',
|
||||
infoCoverage: '60°S–60°N · typically 2–3 h delayed',
|
||||
infoTitle: SATELLITE_TITLE,
|
||||
chip: 'View coverage',
|
||||
focus: [-180, -60, 180, 80],
|
||||
credit: SATELLITE_CREDIT,
|
||||
},
|
||||
};
|
||||
|
||||
/** Assert the built-in description of a product, field by field. */
|
||||
function assertBuiltIn(layer, product) {
|
||||
const expected = BUILT_IN[product];
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.summary.units, expected.units);
|
||||
assert.equal(controls.summary.unitsVerbatim, undefined);
|
||||
assert.equal(controls.summary.coverage, expected.coverage);
|
||||
assert.deepEqual(controls.legend, expected.legend);
|
||||
assert.ok(
|
||||
controls.info.startsWith(`${expected.heading}\n`),
|
||||
`${product} heading: ${controls.info}`,
|
||||
);
|
||||
assert.ok(
|
||||
controls.info.endsWith(`\n${expected.infoCoverage}`),
|
||||
`${product} coverage: ${controls.info}`,
|
||||
);
|
||||
assert.equal(controls.infoTitle, expected.infoTitle);
|
||||
assert.equal(
|
||||
controls.chips.find(({ id }) => id === 'coverage').label,
|
||||
expected.chip,
|
||||
);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.source, 'NOAA nowCOAST');
|
||||
assert.equal(stats.sourceVerbatim, undefined);
|
||||
}
|
||||
|
||||
for (const [id, product, params] of PRODUCTS) {
|
||||
test(`${product}: a manifest without display metadata keeps today's description`, async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const { layer, flights } = await shownLayer(
|
||||
id,
|
||||
{ ...base, product },
|
||||
params,
|
||||
);
|
||||
assertBuiltIn(layer, product);
|
||||
layer.setParams({ focus: true });
|
||||
assert.deepEqual(
|
||||
flights[0],
|
||||
Cesium.Rectangle.fromDegrees(...BUILT_IN[product].focus),
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
for (const [id, product, params] of PRODUCTS) {
|
||||
test(`${product}: empty or wholly invalid display metadata changes nothing`, async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const plain = await shownLayer(id, { ...base, product }, params);
|
||||
const empty = await shownLayer(
|
||||
id,
|
||||
{ ...base, product, display: {} },
|
||||
params,
|
||||
);
|
||||
const invalid = await shownLayer(
|
||||
id,
|
||||
{ ...base, product, display: INVALID },
|
||||
params,
|
||||
);
|
||||
const expected = plain.layer.getRowControls();
|
||||
assert.deepEqual(empty.layer.getRowControls(), expected);
|
||||
assert.deepEqual(invalid.layer.getRowControls(), expected);
|
||||
assertBuiltIn(invalid.layer, product);
|
||||
for (const h of [plain, invalid]) h.layer.setParams({ focus: true });
|
||||
assert.deepEqual(invalid.flights, plain.flights);
|
||||
for (const h of [plain, empty, invalid]) h.layer.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
const EXAMPLE = {
|
||||
source: 'Example network',
|
||||
credit: 'Example network · flash density',
|
||||
units: 'flashes/km²/min',
|
||||
coverage: '52°S–52°N',
|
||||
info: 'Flash density from an example network. Not a safety warning.',
|
||||
legend: [
|
||||
{ label: 0.5, color: '#112233' },
|
||||
{ label: '2', color: '#445566' },
|
||||
{ label: '8+', color: '#AABBCC' },
|
||||
],
|
||||
extent: { west: -180, south: -52, east: 180, north: 52 },
|
||||
};
|
||||
|
||||
test('valid display metadata describes the product in the row, legend and stats', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const { layer, flights } = await shownLayer('weather-lightning', {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: EXAMPLE,
|
||||
});
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.summary.units, 'flashes/km²/min');
|
||||
assert.equal(controls.summary.unitsVerbatim, true);
|
||||
assert.equal(controls.summary.coverage, '52°S–52°N');
|
||||
assert.deepEqual(controls.legend, [
|
||||
{ label: '0.5', color: '#112233', blurb: '0.5 flashes/km²/min' },
|
||||
{ label: '2', color: '#445566', blurb: '2 flashes/km²/min' },
|
||||
{ label: '8+', color: '#AABBCC', blurb: '8+ flashes/km²/min' },
|
||||
]);
|
||||
assert.match(controls.info, /^LIGHTNING DENSITY · 15 min accumulation\n/);
|
||||
assert.match(controls.info, /\n52°S–52°N\nColor: flashes\/km²\/min$/);
|
||||
assert.equal(controls.infoTitle, EXAMPLE.info);
|
||||
assert.equal(
|
||||
controls.chips.find(({ id }) => id === 'coverage').label,
|
||||
'View coverage',
|
||||
);
|
||||
const stats = layer.getStats();
|
||||
assert.equal(stats.source, 'Example network');
|
||||
assert.equal(stats.sourceVerbatim, true);
|
||||
layer.setParams({ focus: true });
|
||||
assert.deepEqual(
|
||||
flights[0],
|
||||
Cesium.Rectangle.fromDegrees(-180, -52, 180, 52),
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('units and legend apply only as a valid pair', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const legend = [
|
||||
{ label: 1, color: '#000000' },
|
||||
{ label: 10, color: '#ffffff' },
|
||||
];
|
||||
for (const [id, product] of [
|
||||
['weather-lightning', 'lightning'],
|
||||
['weather-radar', 'radar'],
|
||||
])
|
||||
for (const display of [
|
||||
{ units: 'mm/h' },
|
||||
{ legend },
|
||||
{ units: 'mm/h', legend: [legend[0]] },
|
||||
{ units: 'x'.repeat(41), legend },
|
||||
]) {
|
||||
const { layer } = await shownLayer(id, { ...base, product, display });
|
||||
assertBuiltIn(layer, product);
|
||||
layer.destroy();
|
||||
}
|
||||
const radar = await shownLayer('weather-radar', {
|
||||
...base,
|
||||
product: 'radar',
|
||||
display: { coverage: 'Europe', units: 'mm/h', legend },
|
||||
});
|
||||
const controls = radar.layer.getRowControls();
|
||||
assert.equal(controls.summary.coverage, 'Europe');
|
||||
assert.equal(controls.summary.units, 'mm/h');
|
||||
assert.equal(controls.summary.unitsVerbatim, true);
|
||||
assert.deepEqual(controls.legend, [
|
||||
{ label: '1', color: '#000000', blurb: '1 mm/h' },
|
||||
{ label: '10', color: '#ffffff', blurb: '10 mm/h' },
|
||||
]);
|
||||
assert.match(controls.info, /^RADAR · mm\/h\n/);
|
||||
assert.match(controls.info, /\nEurope$/);
|
||||
radar.layer.destroy();
|
||||
const clouds = await shownLayer('weather-satellite', {
|
||||
...base,
|
||||
product: 'clouds-regional',
|
||||
display: {
|
||||
units: 'K',
|
||||
legend: [
|
||||
{ label: 'warm', color: '#000000' },
|
||||
{ label: 'cold', color: '#ffffff' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(clouds.layer.getRowControls().summary.units, 'K');
|
||||
assert.deepEqual(clouds.layer.getRowControls().legend, [
|
||||
{ label: 'warm', color: '#000000', blurb: 'warm K' },
|
||||
{ label: 'cold', color: '#ffffff', blurb: 'cold K' },
|
||||
]);
|
||||
clouds.layer.destroy();
|
||||
});
|
||||
|
||||
test('other display fields fall back on their own', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const { layer } = await shownLayer('weather-lightning', {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: { ...INVALID, coverage: 'Example coverage', source: 'Example' },
|
||||
});
|
||||
const controls = layer.getRowControls();
|
||||
assert.equal(controls.summary.coverage, 'Example coverage');
|
||||
assert.equal(controls.summary.units, BUILT_IN.lightning.units);
|
||||
assert.deepEqual(controls.legend, BUILT_IN.lightning.legend);
|
||||
assert.equal(controls.infoTitle, BUILT_IN.lightning.infoTitle);
|
||||
assert.equal(layer.getStats().source, 'Example');
|
||||
layer.destroy();
|
||||
});
|
||||
|
||||
test('the map centre is checked against the source extent when one is given', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const OUTSIDE = 'Map center is outside source coverage';
|
||||
const outside = (layer) => layer.getRowControls().info.includes(OUTSIDE);
|
||||
const at = async (id, product, display, center) => {
|
||||
const h = await shownLayer(
|
||||
id,
|
||||
{ ...base, product, ...(display ? { display } : {}) },
|
||||
undefined,
|
||||
{ center },
|
||||
);
|
||||
const result = outside(h.layer);
|
||||
if (result)
|
||||
assert.equal(
|
||||
h.layer.getRowControls().summary.status,
|
||||
'Map center outside coverage',
|
||||
);
|
||||
h.layer.destroy();
|
||||
return result;
|
||||
};
|
||||
// Without an extent: the manifest bounds, plus lightning's built-in gap.
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', null, [50, 10]),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', null, [-100, 10]),
|
||||
false,
|
||||
);
|
||||
assert.equal(await at('weather-radar', 'radar', null, [50, 10]), false);
|
||||
assert.equal(await at('weather-radar', 'radar', null, [50, -70]), true);
|
||||
// An extent replaces the built-in gap.
|
||||
const global = { extent: { west: -180, south: -52, east: 180, north: 52 } };
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', global, [50, 10]),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await at('weather-lightning', 'lightning', global, [50, 60]),
|
||||
true,
|
||||
);
|
||||
const europe = { extent: { west: -10, south: 35, east: 30, north: 70 } };
|
||||
assert.equal(await at('weather-radar', 'radar', europe, [10, 50]), false);
|
||||
assert.equal(await at('weather-radar', 'radar', europe, [-50, 50]), true);
|
||||
assert.equal(await at('weather-radar', 'radar', europe, [10, 30]), true);
|
||||
// West greater than east crosses the antimeridian.
|
||||
const pacific = { extent: { west: 150, south: -30, east: -120, north: 60 } };
|
||||
for (const [center, expected] of [
|
||||
[[170, 0], false],
|
||||
[[-150, 0], false],
|
||||
[[0, 0], true],
|
||||
[[-100, 0], true],
|
||||
[[170, 70], true],
|
||||
])
|
||||
assert.equal(
|
||||
await at('weather-radar', 'radar', pacific, center),
|
||||
expected,
|
||||
`centre ${center}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('switching to a product without display metadata restores its built-ins', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const h = await shownLayer('weather-satellite', {
|
||||
...base,
|
||||
product: 'clouds-regional',
|
||||
display: EXAMPLE,
|
||||
});
|
||||
h.manifests.clouds = { ...base, product: 'clouds' };
|
||||
assert.equal(h.layer.getStats().source, 'Example network');
|
||||
h.layer.setParams({ product: 'clouds' });
|
||||
await flush();
|
||||
assertBuiltIn(h.layer, 'clouds');
|
||||
h.layer.setParams({ product: 'clouds-regional' });
|
||||
await flush();
|
||||
assert.equal(h.layer.getStats().source, 'Example network');
|
||||
assert.equal(h.layer.getRowControls().summary.coverage, '52°S–52°N');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('a refresh that drops display metadata restores the built-ins', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const h = await shownLayer('weather-lightning', {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: EXAMPLE,
|
||||
});
|
||||
assert.equal(h.layer.getStats().source, 'Example network');
|
||||
const plain = { ...base, product: 'lightning' };
|
||||
h.manifests.lightning = plain;
|
||||
await h.layer.update();
|
||||
assertBuiltIn(h.layer, 'lightning');
|
||||
assert.equal(h.snapshots.at(-1), plain, 'the renderer sees the refresh');
|
||||
h.layer.destroy();
|
||||
});
|
||||
|
||||
test('display validation bounds text, legend shape, colours and extents', () => {
|
||||
const read = (display) => weatherDisplay({ display });
|
||||
const scale = (legend) => read({ units: 'u', legend }).legend;
|
||||
assert.deepEqual(read(undefined), {});
|
||||
assert.deepEqual(read(['credit']), {});
|
||||
assert.deepEqual(read({ coverage: ' Europe ' }), { coverage: 'Europe' });
|
||||
assert.deepEqual(read({ coverage: ' ' }), {});
|
||||
assert.deepEqual(read({ credit: 'a
b' }), {});
|
||||
assert.deepEqual(read({ credit: 'x'.repeat(200) }).credit.length, 200);
|
||||
assert.deepEqual(read({ credit: 'x'.repeat(201) }), {});
|
||||
assert.deepEqual(read({ info: 'x'.repeat(601) }), {});
|
||||
// Invisible and direction-changing characters are rejected in every field.
|
||||
for (const code of [
|
||||
0x00ad, 0x034f, 0x061c, 0x180e, 0x200b, 0x200d, 0x200e, 0x200f, 0x202a,
|
||||
0x202d, 0x202e, 0x2060, 0x2066, 0x2069, 0xfe00, 0xfe0f, 0xfeff, 0xe0000,
|
||||
0xe0001, 0xe0041, 0xe007f,
|
||||
]) {
|
||||
const ch = String.fromCodePoint(code);
|
||||
const value = `a${ch}b`;
|
||||
for (const key of ['source', 'credit', 'units', 'coverage', 'info'])
|
||||
assert.equal(
|
||||
read({ [key]: value, legend: [{ label: 1, color: '#000000' }] })[key],
|
||||
undefined,
|
||||
`${key} with U+${ch.codePointAt(0).toString(16)}`,
|
||||
);
|
||||
assert.equal(
|
||||
scale([
|
||||
{ label: '0', color: '#000000' },
|
||||
{ label: value, color: '#000000' },
|
||||
]),
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
assert.equal(read({ coverage: 'a b · c–d' }).coverage, 'a b · c–d');
|
||||
const stop = (label, color = '#000000') => ({ label, color });
|
||||
assert.equal(scale([stop('1')]), undefined);
|
||||
assert.equal(
|
||||
scale(Array.from({ length: 17 }, (_, i) => stop(String(i)))),
|
||||
undefined,
|
||||
);
|
||||
for (const bad of [
|
||||
stop(Number.NaN),
|
||||
stop(Infinity),
|
||||
stop(0.1 + 0.2),
|
||||
stop(1e21),
|
||||
stop(1e-7),
|
||||
stop(''),
|
||||
stop('x'.repeat(17)),
|
||||
stop('1', '#fff'),
|
||||
stop('1', 'rgb(0,0,0)'),
|
||||
stop('1', '#00000g'),
|
||||
null,
|
||||
'1',
|
||||
])
|
||||
assert.equal(scale([stop('0'), bad]), undefined);
|
||||
// A number label must read back exactly as the legend formats it.
|
||||
for (const bad of [
|
||||
'9999999999999999',
|
||||
'9007199254740992',
|
||||
'9007199254740992+',
|
||||
'007',
|
||||
'00.5',
|
||||
9007199254740992,
|
||||
])
|
||||
assert.equal(scale([stop('0'), stop(bad)]), undefined, String(bad));
|
||||
assert.deepEqual(
|
||||
scale([
|
||||
stop('9007199254740991'),
|
||||
stop(-900719925474099),
|
||||
stop('-0.50'),
|
||||
stop('1.25+'),
|
||||
stop('123456.789012345'),
|
||||
]),
|
||||
[
|
||||
['9007199254740991', '#000000'],
|
||||
['-900719925474099', '#000000'],
|
||||
['-0.50', '#000000'],
|
||||
['1.25+', '#000000'],
|
||||
['123456.789012345', '#000000'],
|
||||
],
|
||||
);
|
||||
for (const label of ['9007199254740991', '-0.50', '1.25+', '0.001'])
|
||||
assert.equal(formatScaleLabel(label), label);
|
||||
assert.equal(formatScaleLabel('9999999999999999'), '10000000000000000');
|
||||
assert.deepEqual(scale([stop(0), stop(-2.5), stop('1', '#ABCDEF')]), [
|
||||
['0', '#000000'],
|
||||
['-2.5', '#000000'],
|
||||
['1', '#ABCDEF'],
|
||||
]);
|
||||
// A legend needs its units and units need their legend.
|
||||
assert.deepEqual(read({ units: 'u' }), {});
|
||||
assert.deepEqual(read({ legend: [stop(0), stop(1)] }), {});
|
||||
for (const bad of [
|
||||
{ west: -10, south: 0, east: 10 },
|
||||
{ west: -10, south: 0, east: 10, north: '5' },
|
||||
{ west: -181, south: 0, east: 10, north: 5 },
|
||||
{ west: -10, south: -91, east: 10, north: 5 },
|
||||
{ west: -10, south: 5, east: 10, north: 5 },
|
||||
{ west: -10, south: 6, east: 10, north: 5 },
|
||||
{ west: 10, south: 0, east: 10, north: 5 },
|
||||
{ west: 180, south: 0, east: -180, north: 5 },
|
||||
{ west: -180, south: 0, east: -180, north: 5 },
|
||||
])
|
||||
assert.equal(read({ extent: bad }).extent, undefined, JSON.stringify(bad));
|
||||
for (const good of [
|
||||
{ west: -180, south: -90, east: 180, north: 90 },
|
||||
{ west: 170, south: 0, east: -180, north: 5 },
|
||||
])
|
||||
assert.deepEqual(read({ extent: good }).extent, good);
|
||||
const wrap = read({ extent: { west: 110, south: -25, east: 0, north: 80 } });
|
||||
assert.equal(insideExtent(wrap.extent, 150, 10), true);
|
||||
assert.equal(insideExtent(wrap.extent, -100, 10), true);
|
||||
assert.equal(insideExtent(wrap.extent, 50, 10), false);
|
||||
assert.equal(insideExtent(wrap.extent, -100, 81), false);
|
||||
assert.equal(
|
||||
escapeCreditText(`<b>"A" & 'B'</b>`),
|
||||
'<b>"A" & 'B'</b>',
|
||||
);
|
||||
});
|
||||
|
||||
function mockCanvas() {
|
||||
const canvas = { width: 0, height: 0 };
|
||||
canvas.getContext = () => ({
|
||||
setTransform() {},
|
||||
drawImage() {},
|
||||
getImageData: () => ({ data: new Uint8ClampedArray([194, 100, 0, 200]) }),
|
||||
putImageData() {},
|
||||
});
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** A globe viewer whose imagery collection records providers. */
|
||||
function globeViewer() {
|
||||
const providers = [];
|
||||
const layers = [];
|
||||
const collection = {
|
||||
addImageryProvider(provider) {
|
||||
providers.push(provider);
|
||||
const layer = { imageryProvider: provider, alpha: 1, show: true };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
contains: (layer) => layers.includes(layer),
|
||||
remove(layer) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
return true;
|
||||
},
|
||||
get length() {
|
||||
return layers.length;
|
||||
},
|
||||
get: (index) => layers[index],
|
||||
indexOf: (layer) => layers.indexOf(layer),
|
||||
lower() {},
|
||||
raise() {},
|
||||
raiseToTop() {},
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
const listeners = new Set();
|
||||
const viewer = {
|
||||
imageryLayers: collection,
|
||||
camera: {
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
flyTo() {},
|
||||
},
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
},
|
||||
primitives: createShellScene().primitives,
|
||||
globe: { tilesLoaded: true },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
class Provider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.tilingScheme = new GeographicTilingScheme();
|
||||
this.errorEvent = new Cesium.Event();
|
||||
}
|
||||
requestImage() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const cesium = {
|
||||
...createShellCesium(),
|
||||
UrlTemplateImageryProvider: Provider,
|
||||
GeographicTilingScheme,
|
||||
Event: Cesium.Event,
|
||||
Credit: Cesium.Credit,
|
||||
Rectangle: Cesium.Rectangle,
|
||||
};
|
||||
const options = {
|
||||
cesium,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
headers: new Headers(),
|
||||
arrayBuffer: async () => new ArrayBuffer(1),
|
||||
}),
|
||||
decodeImage: async () => ({ width: 2048, height: 1024, close() {} }),
|
||||
createCanvas: mockCanvas,
|
||||
};
|
||||
const settle = async () => {
|
||||
await flush();
|
||||
for (let i = 0; i < 2; i++) for (const fn of [...listeners]) fn();
|
||||
await flush();
|
||||
};
|
||||
return { viewer, providers, options, settle };
|
||||
}
|
||||
|
||||
/** Show a frame of `manifest` and return the rendering and its globe. */
|
||||
async function shownFrame(manifest) {
|
||||
const globe = globeViewer();
|
||||
const rendering = createWeatherRendering({
|
||||
viewer: globe.viewer,
|
||||
...globe.options,
|
||||
});
|
||||
const shown = rendering.setFrame(manifest, times[1]);
|
||||
await globe.settle();
|
||||
assert.equal(await shown, true);
|
||||
return { ...globe, rendering };
|
||||
}
|
||||
|
||||
const creditOf = (providers) => providers.at(-1).credit.html;
|
||||
|
||||
test('the imagery credit uses the source credit as plain text, else the built-in one', async () => {
|
||||
for (const product of ['radar', 'lightning', 'clouds-regional', 'clouds']) {
|
||||
for (const display of [undefined, { credit: 42 }]) {
|
||||
const h = await shownFrame({
|
||||
...base,
|
||||
product,
|
||||
...(display ? { display } : {}),
|
||||
});
|
||||
assert.equal(creditOf(h.providers), BUILT_IN[product].credit, product);
|
||||
h.rendering.clear();
|
||||
}
|
||||
const h = await shownFrame({
|
||||
...base,
|
||||
product,
|
||||
display: { credit: 'Example <network> & partners' },
|
||||
});
|
||||
assert.equal(
|
||||
creditOf(h.providers),
|
||||
'Example <network> & partners',
|
||||
);
|
||||
h.rendering.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refreshed manifest updates the credit of the frame already shown', async () => {
|
||||
for (const product of ['lightning', 'clouds']) {
|
||||
const h = await shownFrame({
|
||||
...base,
|
||||
product,
|
||||
display: { credit: 'First credit' },
|
||||
});
|
||||
assert.equal(creditOf(h.providers), 'First credit');
|
||||
const again = (display) =>
|
||||
h.rendering.setFrame(
|
||||
{ ...base, product, ...(display ? { display } : {}) },
|
||||
times[1],
|
||||
);
|
||||
assert.equal(await again({ credit: 'Second credit' }), true);
|
||||
assert.equal(h.providers.length, 1, 'the frame is not restaged');
|
||||
assert.equal(creditOf(h.providers), 'Second credit');
|
||||
assert.equal(await again(), true);
|
||||
assert.equal(creditOf(h.providers), BUILT_IN[product].credit);
|
||||
h.rendering.updateSnapshot({
|
||||
...base,
|
||||
product,
|
||||
display: { credit: 'Third credit' },
|
||||
});
|
||||
assert.equal(creditOf(h.providers), 'Third credit');
|
||||
// Another product's manifest leaves this frame alone.
|
||||
h.rendering.updateSnapshot({
|
||||
...base,
|
||||
product: 'radar',
|
||||
display: { credit: 'Other' },
|
||||
});
|
||||
assert.equal(creditOf(h.providers), 'Third credit');
|
||||
h.rendering.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test('a refreshed manifest leaves the shown snapshot for a host switch to restage', async () => {
|
||||
const globe = globeViewer();
|
||||
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||
const staged = [];
|
||||
const rendering = createWeatherRendering({
|
||||
viewer: globe.viewer,
|
||||
...globe.options,
|
||||
getHost: () => host,
|
||||
createShell: ({ product }) => ({
|
||||
product,
|
||||
setAlpha() {},
|
||||
setHidden() {},
|
||||
rehome: () => false,
|
||||
cancelPrefetch() {},
|
||||
clear() {},
|
||||
async setFrame(snapshot, time) {
|
||||
staged.push([snapshot, time]);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const first = {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: { credit: 'First credit' },
|
||||
};
|
||||
const shown = rendering.setFrame(first, times[0]);
|
||||
await globe.settle();
|
||||
assert.equal(await shown, true);
|
||||
const refreshed = {
|
||||
...first,
|
||||
times: [times[1]],
|
||||
bounds: { west: -10, south: 0, east: 10, north: 5 },
|
||||
display: { credit: 'Second credit' },
|
||||
};
|
||||
rendering.updateSnapshot(refreshed);
|
||||
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||
host = { collection: {}, kind: 'tileset' };
|
||||
assert.equal(rendering.rehome(), true);
|
||||
await flush();
|
||||
assert.deepEqual(staged, [[first, times[0]]]);
|
||||
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||
assert.equal(rendering.rehome(), true);
|
||||
await globe.settle();
|
||||
assert.equal(globe.providers.length, 2);
|
||||
const { west, south, east, north } = first.bounds;
|
||||
assert.ok(
|
||||
Cesium.Rectangle.equals(
|
||||
globe.providers.at(-1).options.rectangle,
|
||||
Cesium.Rectangle.fromDegrees(west, south, east, north),
|
||||
),
|
||||
'the restaged frame keeps the bounds it was staged from',
|
||||
);
|
||||
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||
rendering.clear();
|
||||
});
|
||||
|
||||
test('a refresh during a host-switch restage keeps the newest credit', async () => {
|
||||
const globe = globeViewer();
|
||||
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||
const rendering = createWeatherRendering({
|
||||
viewer: globe.viewer,
|
||||
...globe.options,
|
||||
getHost: () => host,
|
||||
createShell: ({ product }) => ({
|
||||
product,
|
||||
setAlpha() {},
|
||||
setHidden() {},
|
||||
rehome: () => false,
|
||||
cancelPrefetch() {},
|
||||
clear() {},
|
||||
async setFrame() {
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const first = {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: { credit: 'First credit' },
|
||||
};
|
||||
const shown = rendering.setFrame(first, times[0]);
|
||||
await globe.settle();
|
||||
assert.equal(await shown, true);
|
||||
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
|
||||
host = { collection: {}, kind: 'tileset' };
|
||||
assert.equal(rendering.rehome(), true);
|
||||
await flush();
|
||||
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||
assert.equal(rendering.rehome(), true);
|
||||
rendering.updateSnapshot({ ...first, display: { credit: 'Third credit' } });
|
||||
await globe.settle();
|
||||
assert.equal(globe.providers.length, 2);
|
||||
assert.equal(creditOf(globe.providers), 'Third credit');
|
||||
rendering.clear();
|
||||
});
|
||||
|
||||
test('a refresh while a frame loads survives a later host switch', async () => {
|
||||
const globe = globeViewer();
|
||||
let host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||
const rendering = createWeatherRendering({
|
||||
viewer: globe.viewer,
|
||||
...globe.options,
|
||||
getHost: () => host,
|
||||
createShell: ({ product }) => ({
|
||||
product,
|
||||
setAlpha() {},
|
||||
setHidden() {},
|
||||
rehome: () => false,
|
||||
cancelPrefetch() {},
|
||||
clear() {},
|
||||
async setFrame() {
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const first = {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: { credit: 'First credit' },
|
||||
};
|
||||
const shown = rendering.setFrame(first, times[0]);
|
||||
rendering.updateSnapshot({ ...first, display: { credit: 'Second credit' } });
|
||||
await globe.settle();
|
||||
assert.equal(await shown, true);
|
||||
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||
host = { collection: {}, kind: 'tileset' };
|
||||
assert.equal(rendering.rehome(), true);
|
||||
await flush();
|
||||
host = { collection: globe.viewer.imageryLayers, kind: 'globe' };
|
||||
assert.equal(rendering.rehome(), true);
|
||||
await globe.settle();
|
||||
assert.equal(globe.providers.length, 2);
|
||||
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||
rendering.clear();
|
||||
});
|
||||
|
||||
test('a refresh before a frame is installed gives it the newest credit', async () => {
|
||||
for (const product of ['lightning', 'clouds']) {
|
||||
const globe = globeViewer();
|
||||
const rendering = createWeatherRendering({
|
||||
viewer: globe.viewer,
|
||||
...globe.options,
|
||||
});
|
||||
const first = { ...base, product, display: { credit: 'First credit' } };
|
||||
const shown = rendering.setFrame(first, times[1]);
|
||||
rendering.updateSnapshot({
|
||||
...first,
|
||||
display: { credit: 'Second credit' },
|
||||
});
|
||||
await globe.settle();
|
||||
assert.equal(await shown, true);
|
||||
assert.equal(creditOf(globe.providers), 'Second credit', product);
|
||||
rendering.clear();
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer refresh with the same latest frame updates the imagery credit', async (t) => {
|
||||
t.mock.method(Date, 'now', () => Date.parse(times[1]));
|
||||
const globe = globeViewer();
|
||||
let manifest = {
|
||||
...base,
|
||||
product: 'lightning',
|
||||
display: { credit: 'First credit' },
|
||||
};
|
||||
const layer = createWeatherLayer({
|
||||
id: 'weather-lightning',
|
||||
feed: { getSnapshot: async () => manifest },
|
||||
documentRef: quiet(),
|
||||
eventTarget: quiet(),
|
||||
matchMedia: () => quiet(),
|
||||
createRendering: (args) =>
|
||||
createWeatherRendering({ ...args, ...globe.options }),
|
||||
});
|
||||
layer.init(globe.viewer);
|
||||
layer.enable();
|
||||
const first = layer.update();
|
||||
await globe.settle();
|
||||
await first;
|
||||
assert.equal(creditOf(globe.providers), 'First credit');
|
||||
manifest = { ...manifest, display: { credit: 'Second credit' } };
|
||||
await layer.update();
|
||||
assert.equal(globe.providers.length, 1, 'the frame is not restaged');
|
||||
assert.equal(creditOf(globe.providers), 'Second credit');
|
||||
manifest = { ...base, product: 'lightning' };
|
||||
await layer.update();
|
||||
assert.equal(creditOf(globe.providers), BUILT_IN.lightning.credit);
|
||||
layer.destroy();
|
||||
});
|
||||
+85
-49
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { weatherDisplay, insideExtent } from './display.js';
|
||||
import {
|
||||
RADAR_MAX_GAP_MS,
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
@@ -371,6 +372,8 @@ export function createWeatherLayer({
|
||||
}
|
||||
manifest = snapshot;
|
||||
error = null;
|
||||
// The shown frame may stay; its credit follows the refreshed manifest.
|
||||
rendering?.updateSnapshot?.(snapshot);
|
||||
if (clock) {
|
||||
await clock.refresh();
|
||||
if (
|
||||
@@ -467,9 +470,11 @@ export function createWeatherLayer({
|
||||
manifest?.bounds &&
|
||||
runNavigation
|
||||
) {
|
||||
const b = lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds;
|
||||
const b =
|
||||
weatherDisplay(manifest).extent ??
|
||||
(lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds);
|
||||
runNavigation(() =>
|
||||
viewer.camera.flyTo({
|
||||
destination: cesium.Rectangle.fromDegrees(
|
||||
@@ -526,6 +531,8 @@ export function createWeatherLayer({
|
||||
getRowControls() {
|
||||
const shared = clock?.getState();
|
||||
const followLatest = isLatest();
|
||||
// Source-described text is data: shown as is, never translated.
|
||||
const display = weatherDisplay(manifest);
|
||||
const missing =
|
||||
noFrame &&
|
||||
shared?.mode === 'history' &&
|
||||
@@ -592,7 +599,9 @@ export function createWeatherLayer({
|
||||
lon > b.east ||
|
||||
lat < b.south ||
|
||||
lat > b.north ||
|
||||
(lightning && lon > 0 && lon < 110));
|
||||
(display.extent
|
||||
? !insideExtent(display.extent, lon, lat)
|
||||
: lightning && lon > 0 && lon < 110));
|
||||
let detail = time
|
||||
? t('{state} · {time} · {lag}', {
|
||||
state: followLatest ? t('Observed') : t('History'),
|
||||
@@ -606,7 +615,9 @@ export function createWeatherLayer({
|
||||
else if (relation === 'nearest')
|
||||
detail = t('{detail} · nearest', { detail });
|
||||
const infoHeading = radar
|
||||
? t('RADAR REFLECTIVITY · dBZ')
|
||||
? display.units !== undefined
|
||||
? t('RADAR · {units}', { units: display.units })
|
||||
: t('RADAR REFLECTIVITY · dBZ')
|
||||
: lightning
|
||||
? t('LIGHTNING DENSITY · 15 min accumulation')
|
||||
: product === 'clouds'
|
||||
@@ -631,13 +642,24 @@ export function createWeatherLayer({
|
||||
: [t('Observation: unavailable'), loading ? t('loading') : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const infoCoverage = radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? `${t('Americas + Pacific · not individual strikes')}\n${t('Color: strikes/km²/min ×10³')}`
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery');
|
||||
const coverageLine =
|
||||
display.coverage ??
|
||||
(radar
|
||||
? t('Contiguous US · gaps ≠ no rain')
|
||||
: lightning
|
||||
? t('Americas + Pacific · not individual strikes')
|
||||
: product === 'clouds'
|
||||
? t('60°S–60°N · typically 2–3 h delayed')
|
||||
: t('North America · infrared imagery'));
|
||||
const infoCoverage = lightning
|
||||
? `${coverageLine}\n${
|
||||
display.units === undefined
|
||||
? t('Color: strikes/km²/min ×10³')
|
||||
: t('Color: {units}', { units: display.units })
|
||||
}`
|
||||
: coverageLine;
|
||||
const stops =
|
||||
display.legend ?? (radar ? STOPS : lightning ? LIGHTNING_STOPS : []);
|
||||
const infoMotion = motion?.matches
|
||||
? clock
|
||||
? t('Reduced motion · history playback unavailable')
|
||||
@@ -651,13 +673,15 @@ export function createWeatherLayer({
|
||||
: lightning
|
||||
? t('Lightning density · 15 min')
|
||||
: t('Satellite clouds'),
|
||||
coverage: radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America'),
|
||||
coverage:
|
||||
display.coverage ??
|
||||
(radar
|
||||
? 'CONUS'
|
||||
: lightning
|
||||
? t('Americas + Pacific')
|
||||
: product === 'clouds'
|
||||
? t('Global · 60°S–60°N')
|
||||
: t('North America')),
|
||||
shownTime: time,
|
||||
maxGapMinutes: maxGap() / 60_000,
|
||||
detail,
|
||||
@@ -674,7 +698,11 @@ export function createWeatherLayer({
|
||||
: outside
|
||||
? t('Map center outside coverage')
|
||||
: null),
|
||||
units: radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : '',
|
||||
units:
|
||||
display.units ??
|
||||
(radar ? 'dBZ' : lightning ? t('strikes/km²/min ×10³') : ''),
|
||||
// Source units are data: the panel shows them untranslated.
|
||||
...(display.units !== undefined ? { unitsVerbatim: true } : {}),
|
||||
},
|
||||
chips: [
|
||||
...(satellite
|
||||
@@ -723,43 +751,47 @@ export function createWeatherLayer({
|
||||
})),
|
||||
{
|
||||
id: 'coverage',
|
||||
label: radar
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
label:
|
||||
display.coverage !== undefined || display.extent !== undefined
|
||||
? t('View coverage')
|
||||
: radar
|
||||
? t('View US radar')
|
||||
: lightning
|
||||
? t('View Americas & Pacific')
|
||||
: t('View coverage'),
|
||||
disabled: !manifest || !runNavigation,
|
||||
params: { focus: true },
|
||||
},
|
||||
],
|
||||
legend:
|
||||
radar || lightning
|
||||
? (lightning ? LIGHTNING_STOPS : STOPS).map(([label, color]) => ({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: lightning
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
}))
|
||||
: [],
|
||||
legend: stops.map(([label, color]) => ({
|
||||
label: String(label),
|
||||
color,
|
||||
blurb: display.legend
|
||||
? `${formatScaleLabel(label)} ${display.units}`
|
||||
: lightning
|
||||
? t('{value} strikes/km²/min ×10³ (15-minute density)', {
|
||||
value: formatScaleLabel(label),
|
||||
})
|
||||
: t('{value} dBZ radar reflectivity', {
|
||||
value: formatScaleLabel(label),
|
||||
}),
|
||||
})),
|
||||
info: hostHidden
|
||||
? shownHost
|
||||
: `${infoHeading}\n${infoTime}${missing ? `\n${missing}` : ''}${manifest?.stale ? `\n${t('STALE · cached source metadata')}` : ''}${shownProblem ? `\n${shownProblem}` : ''}\n${infoCoverage}${outside ? `\n${t('Map center is outside source coverage')}` : ''}${infoMotion ? `\n${infoMotion}` : ''}`,
|
||||
infoTitle: lightning
|
||||
? t(
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: radar
|
||||
infoTitle:
|
||||
display.info ??
|
||||
(lightning
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
'NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
),
|
||||
: radar
|
||||
? t(
|
||||
'NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.',
|
||||
)
|
||||
: t(
|
||||
'GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.',
|
||||
)),
|
||||
};
|
||||
controls.summary.settings = [
|
||||
...(satellite
|
||||
@@ -805,6 +837,7 @@ export function createWeatherLayer({
|
||||
listener = typeof value === 'function' ? value : null;
|
||||
},
|
||||
getStats() {
|
||||
const { source } = weatherDisplay(manifest);
|
||||
return {
|
||||
count: shownTime() ? 1 : 0,
|
||||
countLabel: isLatest() ? t('Observed') : t('History'),
|
||||
@@ -812,7 +845,10 @@ export function createWeatherLayer({
|
||||
loading,
|
||||
error: error || rendering?.getDiagnostics().error || null,
|
||||
stale: Boolean(manifest?.stale || observationDelayed()),
|
||||
source: 'NOAA nowCOAST',
|
||||
...(source === undefined
|
||||
? { source: 'NOAA nowCOAST' }
|
||||
: // Source text is data: the panel shows it untranslated.
|
||||
{ source, sourceVerbatim: true }),
|
||||
observedAt: shownTime(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { orderWeatherImagery } from './imageryOrder.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { createRasterTileProvider } from './rasterTiles.js';
|
||||
import { createWeatherShell } from './shellRendering.js';
|
||||
import { weatherDisplay, escapeCreditText } from './display.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import {
|
||||
@@ -116,6 +117,38 @@ function createGlobeRendering({
|
||||
return urls;
|
||||
}
|
||||
|
||||
function creditText(snapshot) {
|
||||
const sourceCredit = weatherDisplay(snapshot).credit;
|
||||
return sourceCredit !== undefined
|
||||
? escapeCreditText(sourceCredit)
|
||||
: snapshot.product === 'lightning'
|
||||
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
|
||||
: snapshot.product === 'radar'
|
||||
? 'NOAA nowCOAST · NWS/OAR MRMS'
|
||||
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners';
|
||||
}
|
||||
function setCredit(frame) {
|
||||
const text = creditText(frame.creditSnapshot ?? frame.snapshot);
|
||||
if (frame.credit?.html === text) return false;
|
||||
frame.credit = new cesium.Credit(text, false);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Take the credit of a refreshed manifest of the same product. Each frame
|
||||
* keeps the snapshot it was staged from, whose time and bounds still match,
|
||||
* and remembers the refreshed one for its credit, including a frame that
|
||||
* is still loading and takes its credit when installed.
|
||||
*/
|
||||
function updateSnapshot(snapshot) {
|
||||
let changed = false;
|
||||
for (const frame of new Set([current, incoming, ...retiring])) {
|
||||
if (!frame || frame.product !== snapshot.product) continue;
|
||||
frame.creditSnapshot = snapshot;
|
||||
if (frame.credit && setCredit(frame)) changed = true;
|
||||
}
|
||||
if (changed) viewer.scene.requestRender();
|
||||
}
|
||||
|
||||
function remove(frame) {
|
||||
if (!frame) return;
|
||||
frame.closed = true;
|
||||
@@ -180,6 +213,7 @@ function createGlobeRendering({
|
||||
return {
|
||||
rehome,
|
||||
cancelPrefetch,
|
||||
updateSnapshot,
|
||||
async prefetch(snapshot, time, { infrared = 'filtered' } = {}) {
|
||||
let job;
|
||||
try {
|
||||
@@ -220,7 +254,11 @@ function createGlobeRendering({
|
||||
if (job && prefetchJob === job) prefetchJob = null;
|
||||
}
|
||||
},
|
||||
async setFrame(snapshot, time, { signal, infrared = 'filtered' } = {}) {
|
||||
async setFrame(
|
||||
snapshot,
|
||||
time,
|
||||
{ signal, infrared = 'filtered', creditSnapshot } = {},
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
cancelPrefetch();
|
||||
rehome();
|
||||
@@ -234,14 +272,17 @@ function createGlobeRendering({
|
||||
current.product === snapshot.product &&
|
||||
current.kind === kind &&
|
||||
current.infrared === infrared
|
||||
)
|
||||
) {
|
||||
updateSnapshot(creditSnapshot ?? snapshot);
|
||||
return true;
|
||||
}
|
||||
lastError = null;
|
||||
const { west, south, east, north } = snapshot.bounds;
|
||||
const rectangle = cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
const global = snapshot.product === 'clouds';
|
||||
const frame = {
|
||||
snapshot,
|
||||
creditSnapshot,
|
||||
time,
|
||||
infrared,
|
||||
mosaic: global
|
||||
@@ -342,14 +383,8 @@ function createGlobeRendering({
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
const credit = new cesium.Credit(
|
||||
snapshot.product === 'lightning'
|
||||
? 'NOAA/NWS lightning density · derived from Vaisala NLDN/GLD360'
|
||||
: snapshot.product === 'radar'
|
||||
? 'NOAA nowCOAST · NWS/OAR MRMS'
|
||||
: 'NOAA nowCOAST · NESDIS GOES / global satellite partners',
|
||||
false,
|
||||
);
|
||||
setCredit(frame);
|
||||
const credit = frame.credit;
|
||||
const provider = global
|
||||
? createRasterTileProvider({
|
||||
cesium,
|
||||
@@ -373,6 +408,13 @@ function createGlobeRendering({
|
||||
enablePickFeatures: false,
|
||||
credit,
|
||||
});
|
||||
// Cesium reads a provider's credit every frame; a refreshed manifest
|
||||
// can replace it without restaging the imagery.
|
||||
Object.defineProperty(provider, 'credit', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => frame.credit,
|
||||
});
|
||||
const requestImage = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (frame.closed) return undefined;
|
||||
@@ -574,6 +616,8 @@ export function createWeatherRendering({
|
||||
let hidden = false;
|
||||
let shown = null;
|
||||
let restaging = null;
|
||||
// The last refreshed manifest, so a frame shown after it keeps its credit.
|
||||
let refreshed = null;
|
||||
|
||||
// A host without imagery keeps the active renderer, which retains its frame.
|
||||
const wanted = () => {
|
||||
@@ -602,8 +646,13 @@ export function createWeatherRendering({
|
||||
restaging = null;
|
||||
if (!ok) onChange();
|
||||
};
|
||||
// The restaged frame takes the newest refreshed credit; a refresh while
|
||||
// it loads reaches it through the globe's own updateSnapshot.
|
||||
target
|
||||
.setFrame(frame.snapshot, frame.time, { infrared: frame.infrared })
|
||||
.setFrame(frame.snapshot, frame.time, {
|
||||
infrared: frame.infrared,
|
||||
...(target === globe ? { creditSnapshot: shown?.latest } : {}),
|
||||
})
|
||||
.then(settle, () => settle(false));
|
||||
}
|
||||
function switchHost() {
|
||||
@@ -645,11 +694,31 @@ export function createWeatherRendering({
|
||||
switchHost();
|
||||
restaging = null;
|
||||
const target = active === 'shell' ? shellFor(snapshot.product) : globe;
|
||||
const before = refreshed;
|
||||
const ok = await target.setFrame(snapshot, time, options);
|
||||
if (ok)
|
||||
shown = { snapshot, time, infrared: options.infrared ?? 'filtered' };
|
||||
if (ok) {
|
||||
// Keep a refresh that arrived while this frame loaded.
|
||||
const latest =
|
||||
refreshed !== before && refreshed?.product === snapshot.product
|
||||
? refreshed
|
||||
: undefined;
|
||||
shown = {
|
||||
snapshot,
|
||||
time,
|
||||
infrared: options.infrared ?? 'filtered',
|
||||
...(latest ? { latest } : {}),
|
||||
};
|
||||
}
|
||||
return ok;
|
||||
},
|
||||
// The shown frame keeps its staged snapshot for a restage on another
|
||||
// host; only the refreshed credit is remembered and applied.
|
||||
updateSnapshot(snapshot) {
|
||||
refreshed = snapshot;
|
||||
if (shown?.snapshot.product === snapshot.product)
|
||||
shown = { ...shown, latest: snapshot };
|
||||
globe.updateSnapshot(snapshot);
|
||||
},
|
||||
setHidden(value) {
|
||||
hidden = Boolean(value);
|
||||
if (hidden) restaging = null;
|
||||
@@ -662,6 +731,7 @@ export function createWeatherRendering({
|
||||
clear() {
|
||||
restaging = null;
|
||||
shown = null;
|
||||
refreshed = null;
|
||||
hidden = false;
|
||||
shell?.clear();
|
||||
shell = null;
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
windReadingResult,
|
||||
windUnitChips,
|
||||
} from './presentation.js';
|
||||
import {
|
||||
inspectWindAtCenter,
|
||||
createWindInspectionMarker,
|
||||
WIND_UNITS,
|
||||
} from './inspection.js';
|
||||
import { inspectWindAtCenter, WIND_UNITS } from './inspection.js';
|
||||
import { createWindInspectionMarker } from './inspectionMarker.js';
|
||||
import { t, msg, translateKnown } from '../../i18n/index.js';
|
||||
import { unitLabel } from '../../ui/unitLabels.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWindLayer, formatWindValidTime, windStats } from './index.js';
|
||||
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
|
||||
import { applicationPinia } from '../../ui/vueRuntime.js';
|
||||
|
||||
const snapshot = (model) => ({
|
||||
model,
|
||||
@@ -302,12 +304,12 @@ test('weather summary describes the selected forecast and count remains numeric'
|
||||
});
|
||||
|
||||
test('sample stays fixed across model, field and unit changes; dismissal and disable clear marker', async () => {
|
||||
const nodes = [];
|
||||
// The marker the scene chrome renders, as the wind layer presents it.
|
||||
const marker = () => useWindInspectionStore(applicationPinia()).marker;
|
||||
let listener;
|
||||
let samples = 0;
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
ownerDocument: {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
||||
};
|
||||
const viewer = {
|
||||
@@ -338,9 +340,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
|
||||
layer.setParams({ inspect: true });
|
||||
const captured = layer.getRowControls().summary.reading;
|
||||
assert.equal(samples, 1);
|
||||
const retained = nodes[0];
|
||||
const retained = marker().owner;
|
||||
layer.setParams({ units: 'mph' });
|
||||
assert.equal(nodes[0], retained);
|
||||
assert.ok(marker().owner === retained);
|
||||
const changed = layer.getRowControls().summary.reading;
|
||||
assert.equal(changed.speed, captured.speed);
|
||||
assert.equal(changed.coordinates, captured.coordinates);
|
||||
@@ -348,7 +350,7 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
|
||||
assert.equal(samples, 1, 'units do not resample');
|
||||
assert.equal(layer.getRowControls().summary.result.id, 'reading');
|
||||
assert.deepEqual(layer.getRowControls().summary.settings.map(({ label }) => label), ['MODEL', 'FIELD', 'UNITS', 'MOTION']);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.ok(marker());
|
||||
assert.equal(typeof listener, 'function');
|
||||
viewer.camera.pickEllipsoid = () => { samples++; return { longitude: 70, latitude: 20 }; };
|
||||
layer.setParams({ overlay: 'speed' });
|
||||
@@ -368,9 +370,9 @@ test('sample stays fixed across model, field and unit changes; dismissal and dis
|
||||
assert.equal(samples, 2, 'the next explicit read samples the new center');
|
||||
for (const change of [() => layer.setParams({ inspect: false }), () => layer.disable()]) {
|
||||
layer.setParams({ inspect: true });
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.ok(marker());
|
||||
change();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(marker(), null);
|
||||
assert.equal(layer.getRowControls().summary.reading, null);
|
||||
assert.equal(layer.getRowControls().summary.result, null);
|
||||
assert.equal(listener, null);
|
||||
|
||||
@@ -112,62 +112,3 @@ export function inspectWindAtCenter(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** One passive marker for the captured location; it never samples on camera movement. */
|
||||
export function createWindInspectionMarker({ container, viewer, cesium }) {
|
||||
let removeRender = null;
|
||||
let marker = null;
|
||||
const clear = () => {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
marker?.remove();
|
||||
marker = null;
|
||||
viewer?.scene?.requestRender?.();
|
||||
};
|
||||
return {
|
||||
show(position) {
|
||||
clear();
|
||||
const scene = viewer?.scene;
|
||||
if (
|
||||
!position ||
|
||||
!container?.ownerDocument?.createElement ||
|
||||
!scene?.postRender?.addEventListener ||
|
||||
!scene.cartesianToCanvasCoordinates
|
||||
)
|
||||
return;
|
||||
marker = container.ownerDocument.createElement('div');
|
||||
marker.className = 'gev-wind-inspection-marker';
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
marker.style.cssText =
|
||||
'position:absolute;width:18px;height:18px;border:2px solid #fff;border-radius:50%;box-shadow:0 0 0 2px #153440,0 0 0 5px rgba(102,225,242,.35);transform:translate(-50%,-50%);pointer-events:none;z-index:150;';
|
||||
container.appendChild(marker);
|
||||
const occluder = new cesium.EllipsoidalOccluder(
|
||||
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const render = () => {
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = scene.cartesianToCanvasCoordinates(position);
|
||||
const visible =
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= scene.canvas.clientWidth &&
|
||||
point.y <= scene.canvas.clientHeight &&
|
||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
||||
occluder.isPointVisible(position));
|
||||
marker.hidden = !visible;
|
||||
if (!visible) return;
|
||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
marker.style.left = `${point.x + canvasRect.left - containerRect.left}px`;
|
||||
marker.style.top = `${point.y + canvasRect.top - containerRect.top}px`;
|
||||
};
|
||||
removeRender = scene.postRender.addEventListener(render);
|
||||
render();
|
||||
scene.requestRender?.();
|
||||
},
|
||||
clear,
|
||||
destroy: clear,
|
||||
};
|
||||
}
|
||||
@@ -58,55 +58,3 @@ test('center inspection uses a fixed forecast reading without double-converting
|
||||
'No surface reading',
|
||||
);
|
||||
});
|
||||
|
||||
test('inspection marker follows only its captured point, hides beyond limb, and releases its listener', async () => {
|
||||
const { createWindInspectionMarker } = await import('./inspection.js');
|
||||
let listener = null;
|
||||
let visible = true;
|
||||
const projected = [];
|
||||
const nodes = [];
|
||||
const container = {
|
||||
ownerDocument: { createElement: () => ({ style: {}, setAttribute() {}, remove() { nodes.splice(nodes.indexOf(this), 1); } }) },
|
||||
appendChild(node) { nodes.push(node); },
|
||||
getBoundingClientRect: () => ({ left: 5, top: 10 }),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { positionWC: {} },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: { clientWidth: 800, clientHeight: 600, getBoundingClientRect: () => ({ left: 15, top: 30 }) },
|
||||
postRender: { addEventListener(fn) { assert.equal(listener, null); listener = fn; return () => { listener = null; }; } },
|
||||
cartesianToCanvasCoordinates(point) { projected.push(point); return { x: 100, y: 200 }; },
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const cesium = {
|
||||
SceneMode: { SCENE3D: 3 }, Ellipsoid: { WGS84: {} },
|
||||
EllipsoidalOccluder: class { isPointVisible() { return visible; } },
|
||||
};
|
||||
const owner = createWindInspectionMarker({ container, viewer, cesium });
|
||||
const point = { x: 1, y: 2, z: 3 };
|
||||
owner.show(point);
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.match(nodes[0].style.cssText, /pointer-events:none/);
|
||||
assert.equal(nodes[0].style.left, '110px');
|
||||
assert.equal(nodes[0].style.top, '220px');
|
||||
viewer.camera.positionWC = { moved: true };
|
||||
visible = false;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, true);
|
||||
assert.ok(projected.every((value) => value === point));
|
||||
visible = true;
|
||||
listener();
|
||||
assert.equal(nodes[0].hidden, false);
|
||||
owner.show({ x: 4 });
|
||||
assert.equal(nodes.length, 1);
|
||||
owner.clear();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
owner.show(point);
|
||||
owner.destroy();
|
||||
owner.destroy();
|
||||
assert.equal(nodes.length, 0);
|
||||
assert.equal(listener, null);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
|
||||
import { applicationPinia } from '../../ui/vueRuntime.js';
|
||||
|
||||
/**
|
||||
* One passive marker for the captured location; it never samples on camera
|
||||
* movement. Where the point falls is presented to the wind inspection store
|
||||
* after every rendered frame, and the scene chrome renders the marker in
|
||||
* `container`.
|
||||
*/
|
||||
export function createWindInspectionMarker({
|
||||
container,
|
||||
viewer,
|
||||
cesium,
|
||||
store = useWindInspectionStore(applicationPinia()),
|
||||
}) {
|
||||
const owner = {};
|
||||
let removeRender = null;
|
||||
let marker = null;
|
||||
// Only the owner whose marker the store shows may move or remove it.
|
||||
const shown = () => marker && store.marker?.owner === owner;
|
||||
const present = (patch) => {
|
||||
marker = { ...marker, ...patch };
|
||||
store.publish(marker);
|
||||
};
|
||||
const clear = () => {
|
||||
removeRender?.();
|
||||
removeRender = null;
|
||||
if (shown()) store.publish(null);
|
||||
marker = null;
|
||||
viewer?.scene?.requestRender?.();
|
||||
};
|
||||
return {
|
||||
show(position) {
|
||||
clear();
|
||||
const scene = viewer?.scene;
|
||||
if (
|
||||
!position ||
|
||||
!container?.ownerDocument ||
|
||||
!scene?.postRender?.addEventListener ||
|
||||
!scene.cartesianToCanvasCoordinates
|
||||
)
|
||||
return;
|
||||
present({ owner, container, visible: false, left: 0, top: 0 });
|
||||
const occluder = new cesium.EllipsoidalOccluder(
|
||||
scene.globe?.ellipsoid || cesium.Ellipsoid.WGS84,
|
||||
viewer.camera.positionWC,
|
||||
);
|
||||
const render = () => {
|
||||
if (!shown()) return;
|
||||
occluder.cameraPosition = viewer.camera.positionWC;
|
||||
const point = scene.cartesianToCanvasCoordinates(position);
|
||||
const visible =
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= scene.canvas.clientWidth &&
|
||||
point.y <= scene.canvas.clientHeight &&
|
||||
(scene.mode !== cesium.SceneMode.SCENE3D ||
|
||||
occluder.isPointVisible(position));
|
||||
if (!visible) {
|
||||
// A hidden marker keeps its last place, as the element did.
|
||||
if (marker.visible) present({ visible: false });
|
||||
return;
|
||||
}
|
||||
const canvasRect = scene.canvas.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const left = point.x + canvasRect.left - containerRect.left;
|
||||
const top = point.y + canvasRect.top - containerRect.top;
|
||||
if (marker.visible && marker.left === left && marker.top === top)
|
||||
return;
|
||||
present({ visible: true, left, top });
|
||||
};
|
||||
removeRender = scene.postRender.addEventListener(render);
|
||||
render();
|
||||
scene.requestRender?.();
|
||||
},
|
||||
clear,
|
||||
destroy: clear,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/layers/wind/inspectionMarker.test.mjs — the wind inspection marker
|
||||
// follows only its captured point, renders in its container, and releases
|
||||
// its frame listener.
|
||||
import '../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useWindInspectionStore } from '../../ui/stores/windInspection.js';
|
||||
import WindInspectionMarker from '../../ui/views/panels/WindInspectionMarker.vue';
|
||||
import { createWindInspectionMarker } from './inspectionMarker.js';
|
||||
|
||||
const cesium = {
|
||||
SceneMode: { SCENE3D: 3 },
|
||||
Ellipsoid: { WGS84: {} },
|
||||
EllipsoidalOccluder: class {
|
||||
isPointVisible() {
|
||||
return cesium.visible;
|
||||
}
|
||||
},
|
||||
visible: true,
|
||||
};
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const container = document.body.appendChild(document.createElement('div'));
|
||||
container.getBoundingClientRect = () => ({ left: 5, top: 10 });
|
||||
const pinia = createPinia();
|
||||
const app = createApp(WindInspectionMarker).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const frames = { listener: null };
|
||||
const projected = [];
|
||||
const viewer = {
|
||||
camera: { positionWC: {} },
|
||||
scene: {
|
||||
mode: 3,
|
||||
canvas: {
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
getBoundingClientRect: () => ({ left: 15, top: 30 }),
|
||||
},
|
||||
postRender: {
|
||||
addEventListener(fn) {
|
||||
assert.equal(frames.listener, null);
|
||||
frames.listener = fn;
|
||||
return () => {
|
||||
frames.listener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
cartesianToCanvasCoordinates(point) {
|
||||
projected.push(point);
|
||||
return { x: 100, y: 200 };
|
||||
},
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
const store = useWindInspectionStore(pinia);
|
||||
cesium.visible = true;
|
||||
const owner = createWindInspectionMarker({
|
||||
container,
|
||||
viewer,
|
||||
cesium,
|
||||
store,
|
||||
});
|
||||
const nodes = () => [
|
||||
...container.querySelectorAll('.gev-wind-inspection-marker'),
|
||||
];
|
||||
return { owner, viewer, store, frames, projected, nodes, container, cesium };
|
||||
}
|
||||
|
||||
test('the marker follows only its captured point, hides beyond the limb, and releases its listener', async (t) => {
|
||||
const { owner, viewer, frames, projected, nodes } = fixture(t);
|
||||
const point = { x: 1, y: 2, z: 3 };
|
||||
owner.show(point);
|
||||
await nextTick();
|
||||
assert.equal(nodes().length, 1);
|
||||
const [marker] = nodes();
|
||||
assert.equal(marker.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(marker.style.pointerEvents, 'none');
|
||||
assert.equal(marker.style.position, 'absolute');
|
||||
assert.equal(marker.style.zIndex, '150');
|
||||
assert.equal(marker.style.left, '110px');
|
||||
assert.equal(marker.style.top, '220px');
|
||||
assert.equal(marker.hidden, false);
|
||||
viewer.camera.positionWC = { moved: true };
|
||||
cesium.visible = false;
|
||||
frames.listener();
|
||||
await nextTick();
|
||||
assert.equal(marker.hidden, true);
|
||||
assert.equal(marker.style.left, '110px', 'a hidden marker keeps its place');
|
||||
assert.ok(projected.every((value) => value === point));
|
||||
cesium.visible = true;
|
||||
frames.listener();
|
||||
await nextTick();
|
||||
assert.equal(marker.hidden, false);
|
||||
owner.show({ x: 4 });
|
||||
await nextTick();
|
||||
assert.equal(nodes().length, 1);
|
||||
owner.clear();
|
||||
await nextTick();
|
||||
assert.equal(nodes().length, 0);
|
||||
assert.equal(frames.listener, null);
|
||||
owner.show(point);
|
||||
owner.destroy();
|
||||
owner.destroy();
|
||||
await nextTick();
|
||||
assert.equal(nodes().length, 0);
|
||||
assert.equal(frames.listener, null);
|
||||
});
|
||||
|
||||
test('an unchanged frame publishes nothing, and an older owner leaves a newer marker', (t) => {
|
||||
const { owner, viewer, store, frames, container } = fixture(t);
|
||||
owner.show({ x: 1 });
|
||||
let publishes = 0;
|
||||
const publish = store.publish;
|
||||
store.publish = (next) => {
|
||||
publishes += 1;
|
||||
publish(next);
|
||||
};
|
||||
frames.listener();
|
||||
assert.equal(publishes, 0);
|
||||
frames.listener = null;
|
||||
const newer = createWindInspectionMarker({
|
||||
container,
|
||||
viewer,
|
||||
cesium,
|
||||
store,
|
||||
});
|
||||
newer.show({ x: 2 });
|
||||
const shown = store.marker;
|
||||
owner.destroy();
|
||||
assert.ok(store.marker === shown);
|
||||
newer.destroy();
|
||||
assert.equal(store.marker, null);
|
||||
});
|
||||
Reference in new issue
Block a user