mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 12:54:01 +00:00
Merge main into prereq-10-osm-overlays and port Open Map Overlays
Main moved on since this PR opened; the overlay layer is ported to the current contracts: - Layer-state token 6 (j belongs to transit; new single-character tokens are digits) with a reservation-ledger row, and the option owner renamed to the layer id so the compact share codec can encode it. - Constructed per catalog by createOsmOverlaysLayer() in constructCatalog.js; src/standalone/data.js no longer registers layers. - Drapes through maps/imageryHost.js, so the overlays show on Google 3D (tileset imagery) as well as the globe stacks, and follow map-stack switches. Imagery layers are created on enable and destroyed on disable/destroy. - Translated chip labels and layer name in every locale, LAYER_NAME_KEYS, Infrastructure panel group, voice manifest entry. - Attribution: OpenSeaMap tiles CC BY-SA 2.0, OpenSnowMap credit as required, OSM ODbL credit for both; bulk-download note in DATA_SOURCES. - CURRENT-STATE row, changelog fragment, share-codec absent defaults, updated token-ledger fixtures and voice schema pins.
This commit is contained in:
commit
ce1ad3b95a
2330 files changed
+632956
-84310
No files matched your search
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../i18n/index.js';
|
||||
import UiRail from './kit/UiRail.vue';
|
||||
import {
|
||||
PANEL_RAIL_FALLBACK_ICON,
|
||||
PANEL_RAIL_ITEMS,
|
||||
} from './panelRailCatalog.js';
|
||||
import { usePanelStore } from './stores/panels.js';
|
||||
|
||||
const props = defineProps({
|
||||
/** `left` or `right`. */
|
||||
side: { type: String, required: true },
|
||||
/** Open or close a panel; receives the panel id. */
|
||||
onSelect: { type: Function, required: true },
|
||||
});
|
||||
|
||||
const panels = usePanelStore();
|
||||
const items = computed(() =>
|
||||
panels.sides[props.side]
|
||||
.filter((panel) => panel.available)
|
||||
.map((panel) => {
|
||||
const item = PANEL_RAIL_ITEMS[panel.id];
|
||||
return {
|
||||
id: panel.id,
|
||||
icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON,
|
||||
// Static markup is translated at boot, so a host's title already is.
|
||||
label: item ? t(item.label) : panel.title,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const active = computed(() => panels.openPanelId(props.side));
|
||||
const label = computed(() =>
|
||||
props.side === 'left' ? t('Left panels') : t('Right panels'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiRail
|
||||
:items="items"
|
||||
:active="active"
|
||||
:side="side"
|
||||
:label="label"
|
||||
:panel-open="active !== null"
|
||||
collapsible
|
||||
@select="onSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
/** Own the shared aircraft display preference and its controls. */
|
||||
export class AircraftDisplay {
|
||||
constructor({
|
||||
elements,
|
||||
readDataManager,
|
||||
layout,
|
||||
store = useDisplayStore(applicationPinia()),
|
||||
}) {
|
||||
Object.assign(this, elements, {
|
||||
readDataManager,
|
||||
store,
|
||||
_layoutRightPanels: layout,
|
||||
});
|
||||
// Both aircraft layers default to models enabled in proximity mode.
|
||||
this._models3dEnabled = true;
|
||||
this._models3dMode = 'proximity';
|
||||
// What the button, mode row and mode segments show; null until first
|
||||
// synced, so the initial markup stands. Enabling from the button leaves
|
||||
// the row to its own sync, so each is held apart.
|
||||
this._models3dPressedShown = null;
|
||||
this._models3dRowShown = null;
|
||||
this._models3dModeShown = null;
|
||||
}
|
||||
get _dataManager() {
|
||||
return this.readDataManager();
|
||||
}
|
||||
_setModels3dParams(params, { origin = 'user' } = {}) {
|
||||
this._dataManager?.setLayerParams('flights', params, { origin });
|
||||
this._dataManager?.setLayerParams('military', params, { origin });
|
||||
}
|
||||
|
||||
_syncModels3dFromLayerState(state) {
|
||||
const options = state?.options?.flights;
|
||||
if (!options) return;
|
||||
this._models3dEnabled = options.models3d === true;
|
||||
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
|
||||
this._models3dPressedShown = this._models3dEnabled;
|
||||
this._models3dRowShown = this._models3dEnabled;
|
||||
this._models3dModeShown = this._models3dMode;
|
||||
this._publishModels3d();
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
|
||||
_syncModels3dModeRow() {
|
||||
this._models3dRowShown = this._models3dEnabled;
|
||||
this._publishModels3d();
|
||||
this._layoutRightPanels();
|
||||
}
|
||||
|
||||
/** Present the 3D aircraft group as its controls show it. */
|
||||
_publishModels3d() {
|
||||
this.store.publishModels3d({
|
||||
pressed: this._models3dPressedShown,
|
||||
rowVisible: this._models3dRowShown,
|
||||
mode: this._models3dModeShown,
|
||||
});
|
||||
}
|
||||
|
||||
_initModels3dToggle() {
|
||||
if (!this._models3dBtn) return;
|
||||
this._syncModels3dButtonState();
|
||||
this._syncModels3dModeRow();
|
||||
}
|
||||
|
||||
_setModels3dEnabled(enabled) {
|
||||
this._models3dEnabled = !!enabled;
|
||||
this._setModels3dParams({ models3d: this._models3dEnabled });
|
||||
this._syncModels3dButtonState();
|
||||
}
|
||||
|
||||
_setModels3dMode(mode) {
|
||||
const normalized = mode === 'all' ? 'all' : 'proximity';
|
||||
this._models3dMode = normalized;
|
||||
this._setModels3dParams({ models3dMode: normalized });
|
||||
this._models3dModeShown = normalized;
|
||||
this._syncModels3dButtonState();
|
||||
}
|
||||
|
||||
_syncModels3dButtonState() {
|
||||
// The lit/dark state is a colour to a sighted operator and nothing at all to
|
||||
// a screen reader without aria-pressed, which the view renders with it. It
|
||||
// matters more now that the button ships ACTIVE from markup (default-on,
|
||||
// 2026-08-22): the very first thing assistive tech reported was an
|
||||
// unpressed-looking control over an armed layer.
|
||||
this._models3dPressedShown = this._models3dEnabled;
|
||||
this._publishModels3d();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
const STYLE_KEYS = Object.freeze({
|
||||
1: 'normal',
|
||||
2: 'retro',
|
||||
3: 'surveillance',
|
||||
4: 'thermal',
|
||||
5: 'anime',
|
||||
6: 'noir',
|
||||
});
|
||||
|
||||
/**
|
||||
* Bind the application's existing bubbling keyboard shortcuts.
|
||||
* Capture-phase surfaces keep first refusal. The caller owns each action and
|
||||
* persistence; form controls keep native typing except for Escape.
|
||||
* @param {object} options
|
||||
* @param {Document} options.documentRef Keyboard event target.
|
||||
* @param {HTMLElement} options.searchInput Additional editing target.
|
||||
* @param {object} options.actions Existing application operations.
|
||||
* @returns {{destroy: Function}} Synchronous, idempotent listener cleanup.
|
||||
*/
|
||||
export function bindApplicationShortcuts({
|
||||
documentRef,
|
||||
searchInput,
|
||||
actions,
|
||||
}) {
|
||||
const onKeyDown = (event) => {
|
||||
const isFormControl =
|
||||
event.target?.matches?.('select, input, textarea') ||
|
||||
event.target === searchInput;
|
||||
if (isFormControl && event.key !== 'Escape') return;
|
||||
|
||||
if (STYLE_KEYS[event.key]) actions.setStyle(STYLE_KEYS[event.key]);
|
||||
if (event.key === 'Escape') actions.dismissSearch();
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'h') actions.toggleHud();
|
||||
if (key === 'o') actions.toggleOrbit();
|
||||
if (key === 'v') actions.toggleCleanView();
|
||||
if (key === 'f') actions.toggleLayers();
|
||||
if (key === 'd') actions.cycleDetection();
|
||||
if (key === 'c') actions.toggleCctv();
|
||||
};
|
||||
documentRef.addEventListener('keydown', onKeyDown);
|
||||
return {
|
||||
destroy() {
|
||||
documentRef.removeEventListener('keydown', onKeyDown);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// src/ui/awarenessPanel.test.mjs — the Contacts panel renders what the
|
||||
// Contacts layer publishes (`subscribePanel()`), over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
import AwarenessPanel from './views/panels/AwarenessPanel.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(AwarenessPanel).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useContextStore(pinia);
|
||||
const calls = [];
|
||||
store.connectAwareness({
|
||||
navigatePrevious: (options) => calls.push(['previous', options]),
|
||||
focusCurrent: (options) => calls.push(['focus', options]),
|
||||
navigateNext: (options) => calls.push(['next', options]),
|
||||
focusTarget: (layer, id, options) =>
|
||||
calls.push([`target:${layer}:${id}`, options]),
|
||||
});
|
||||
const panel = () => document.getElementById('military-awareness-panel');
|
||||
const publish = async (view) => {
|
||||
store.publishAwareness(view);
|
||||
await nextTick();
|
||||
};
|
||||
return { panel, publish, calls };
|
||||
}
|
||||
|
||||
const target = (layer, id, label, extra = {}) => ({
|
||||
key: `target:${layer}:${id}`,
|
||||
layer,
|
||||
targetId: id,
|
||||
label,
|
||||
distance: '4.6 km',
|
||||
ariaLabel: `Focus ${label}`,
|
||||
names: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const results = ({ nearest = [], previousDisabled = true } = {}) => ({
|
||||
kind: 'results',
|
||||
subject: 'N123AB · 250 km FLIGHT / VESSEL WINDOW',
|
||||
subjectNames: null,
|
||||
navigation: {
|
||||
label: 'Global Context navigation',
|
||||
previous: {
|
||||
text: 'PREVIOUS',
|
||||
title: 'Previous',
|
||||
disabled: previousDisabled,
|
||||
},
|
||||
focus: { text: 'FOCUS' },
|
||||
next: { text: 'NEXT', title: 'Next', disabled: false },
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
id: 'flights',
|
||||
relationship: 'observed',
|
||||
label: 'Flights',
|
||||
count: '2',
|
||||
detail: 'ADS-B · CURRENT VIEWPORT ONLY · nearest',
|
||||
nearest,
|
||||
},
|
||||
],
|
||||
note: 'Open-source mapped/observed context.',
|
||||
});
|
||||
|
||||
test('before the layer publishes, the panel shows its initial markup', (t) => {
|
||||
const { panel } = fixture(t);
|
||||
const standby = panel().querySelector('.military-awareness-standby');
|
||||
assert.equal(
|
||||
standby.querySelector('strong').textContent,
|
||||
'CONTACTS CONTEXT OFF',
|
||||
);
|
||||
// Rendered through t(), so a later null view is translated too.
|
||||
assert.equal(
|
||||
standby.querySelector('strong').hasAttribute('data-i18n'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a standby view replaces the initial copy', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
await publish({
|
||||
kind: 'standby',
|
||||
title: 'CONTEXT READY',
|
||||
text: 'SELECT A FLIGHT',
|
||||
});
|
||||
const standby = panel().querySelector('.military-awareness-standby');
|
||||
assert.equal(standby.querySelector('strong').textContent, 'CONTEXT READY');
|
||||
assert.equal(standby.querySelector('span').textContent, 'SELECT A FLIGHT');
|
||||
await publish({ kind: 'empty' });
|
||||
assert.equal(panel().children.length, 0);
|
||||
});
|
||||
|
||||
test('results render the rows, controls and names as text, and buttons call the layer', async (t) => {
|
||||
const { panel, publish, calls } = fixture(t);
|
||||
const view = results({
|
||||
nearest: [
|
||||
target('flights', 'abc123', 'ALPHA', {
|
||||
names: {
|
||||
summary: 'Named areas (2)',
|
||||
names: ['Area one', '<Area two>'],
|
||||
},
|
||||
}),
|
||||
{
|
||||
key: 'unavailable:1',
|
||||
label: '—',
|
||||
distance: '9 km',
|
||||
ariaLabel: 'Unavailable',
|
||||
},
|
||||
],
|
||||
});
|
||||
await publish(view);
|
||||
const root = panel();
|
||||
assert.equal(
|
||||
root.querySelector('.military-awareness-subject').textContent,
|
||||
view.subject,
|
||||
);
|
||||
const row = root.querySelector('.military-awareness-row');
|
||||
assert.equal(row.className, 'military-awareness-row observed');
|
||||
assert.equal(row.querySelector('b').getAttribute('aria-live'), 'polite');
|
||||
assert.equal(row.querySelector('b').textContent, '2');
|
||||
assert.equal(row.querySelector('small').textContent, view.rows[0].detail);
|
||||
const alpha = row.querySelector('button[data-awareness-id="abc123"]');
|
||||
assert.equal(alpha.dataset.awarenessLayer, 'flights');
|
||||
assert.equal(alpha.getAttribute('aria-label'), 'Focus ALPHA');
|
||||
assert.equal(alpha.textContent, 'ALPHA 4.6 km');
|
||||
const names = row.querySelector('.military-awareness-names div');
|
||||
assert.equal(
|
||||
names.innerHTML.replace(/<!--[^>]*-->/g, ''),
|
||||
'Area one<br><Area two>',
|
||||
);
|
||||
const unavailable = row.querySelector(
|
||||
'span.military-awareness-target.unavailable',
|
||||
);
|
||||
assert.equal(unavailable.getAttribute('aria-label'), 'Unavailable');
|
||||
|
||||
const [previous, focus, next] = root.querySelectorAll(
|
||||
'[data-awareness-action]',
|
||||
);
|
||||
assert.equal(previous.disabled, true);
|
||||
assert.equal(next.title, 'Next');
|
||||
focus.click();
|
||||
next.click();
|
||||
alpha.click();
|
||||
const user = { origin: 'user' };
|
||||
assert.deepEqual(calls, [
|
||||
['focus', user],
|
||||
['next', user],
|
||||
['target:flights:abc123', user],
|
||||
]);
|
||||
const note = root.querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(note.tagName, 'P');
|
||||
assert.equal(note.getAttribute('tabindex'), '-1');
|
||||
});
|
||||
|
||||
test('a live repaint keeps a focused contact by identity and never retargets another', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const alpha = target('flights', 'abc123', 'ALPHA');
|
||||
const bravo = target('flights', 'def456', 'BRAVO');
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
const alphaButton = panel().querySelector('[data-awareness-id="abc123"]');
|
||||
alphaButton.focus();
|
||||
|
||||
await publish(results({ nearest: [bravo, alpha] }));
|
||||
assert.equal(
|
||||
document.activeElement === alphaButton,
|
||||
true,
|
||||
'identity wins when rows reorder',
|
||||
);
|
||||
|
||||
const charlie = target('ais-live-vessels', '789', 'CHARLIE');
|
||||
await publish(results({ nearest: [charlie] }));
|
||||
const note = panel().querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(
|
||||
document.activeElement === note,
|
||||
true,
|
||||
'a departed contact continues at the note',
|
||||
);
|
||||
|
||||
await publish(results({ nearest: [bravo] }));
|
||||
assert.equal(
|
||||
document.activeElement === note,
|
||||
true,
|
||||
'later repaints keep the continuation point',
|
||||
);
|
||||
});
|
||||
|
||||
test('a reorder that drops focus from a moved contact restores the same contact', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const alpha = target('flights', 'abc123', 'ALPHA');
|
||||
const bravo = target('flights', 'def456', 'BRAVO');
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
// Browsers drop focus from a node that is moved; happy-dom does not, so
|
||||
// moving the focused node sends focus outside the panel here.
|
||||
const sink = document.body.appendChild(document.createElement('button'));
|
||||
const list = panel().querySelector('ul');
|
||||
const insertBefore = list.insertBefore.bind(list);
|
||||
list.insertBefore = (node, ref) => {
|
||||
const moved = node.contains(document.activeElement);
|
||||
const result = insertBefore(node, ref);
|
||||
if (moved) sink.focus();
|
||||
return result;
|
||||
};
|
||||
const focusNode = (id) =>
|
||||
panel().querySelector(`[data-awareness-id="${id}"]`);
|
||||
focusNode('def456').focus();
|
||||
await publish(results({ nearest: [bravo, alpha] }));
|
||||
focusNode('abc123').focus();
|
||||
await publish(results({ nearest: [alpha, bravo] }));
|
||||
assert.equal(
|
||||
document.activeElement === focusNode('abc123'),
|
||||
true,
|
||||
'the same contact keeps focus, not another one or the note',
|
||||
);
|
||||
});
|
||||
|
||||
test('a repaint that disables the focused control continues at the note', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
await publish(results({ previousDisabled: false }));
|
||||
const previous = panel().querySelector('[data-awareness-action="previous"]');
|
||||
previous.focus();
|
||||
await publish(results({ previousDisabled: true }));
|
||||
assert.equal(previous.disabled, true);
|
||||
const note = panel().querySelector('[data-awareness-focus-continuation]');
|
||||
assert.equal(document.activeElement === note, true);
|
||||
});
|
||||
|
||||
test('a repaint that leaves focus outside the panel does not pull it back', async (t) => {
|
||||
const { panel, publish } = fixture(t);
|
||||
const outside = document.body.appendChild(document.createElement('button'));
|
||||
await publish(results({ nearest: [target('flights', 'abc123', 'ALPHA')] }));
|
||||
outside.focus();
|
||||
await publish(results({ nearest: [] }));
|
||||
assert.equal(document.activeElement === outside, true);
|
||||
assert.equal(
|
||||
panel().querySelector('ul'),
|
||||
null,
|
||||
'an empty cohort renders no list',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
function trackedTarget(viewer) {
|
||||
const entity = viewer.trackedEntity;
|
||||
// Read the visual's sample first; sampling a flight CallbackProperty twice
|
||||
// can advance dead reckoning ahead of the displayed aircraft.
|
||||
return (
|
||||
entity?.gevDisplayPosition?.() ||
|
||||
entity?.position?.getValue(viewer.clock?.currentTime)
|
||||
);
|
||||
}
|
||||
|
||||
export const OBLIQUE_PITCH = Cesium.Math.toRadians(-35);
|
||||
export const STRAIGHT_DOWN_PITCH = Cesium.Math.toRadians(-89);
|
||||
const OBLIQUE_THRESHOLD = Cesium.Math.toRadians(-60);
|
||||
|
||||
function normalizedHeading(heading) {
|
||||
const wrapped = Cesium.Math.zeroToTwoPi(
|
||||
Number.isFinite(heading) ? heading : 0,
|
||||
);
|
||||
return Math.abs(wrapped - Cesium.Math.TWO_PI) < Cesium.Math.EPSILON10
|
||||
? 0
|
||||
: wrapped;
|
||||
}
|
||||
|
||||
/** Return the world position under the center of the canvas, when available. */
|
||||
export function pickViewTarget(viewer) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
const canvas = scene?.canvas;
|
||||
if (!scene || !camera || !canvas) return null;
|
||||
const width = canvas.clientWidth || canvas.width || 0;
|
||||
const height = canvas.clientHeight || canvas.height || 0;
|
||||
if (!width || !height) return null;
|
||||
const center = new Cesium.Cartesian2(width / 2, height / 2);
|
||||
let target = null;
|
||||
|
||||
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') {
|
||||
try {
|
||||
target = scene.pickPosition(center);
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
// Terrain before the ellipsoid: globe.pick intersects the rendered surface,
|
||||
// so over keyless elevated terrain it returns the ground the operator is
|
||||
// looking at. pickEllipsoid answers with sea level, which on a plateau sits
|
||||
// far below the view and would swing the camera on every tilt.
|
||||
if (
|
||||
!isPickedWorldPosition(target) &&
|
||||
typeof camera.getPickRay === 'function'
|
||||
) {
|
||||
try {
|
||||
target = scene.globe?.pick(camera.getPickRay(center), scene) || null;
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(target) &&
|
||||
typeof camera.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
target = camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
return isPickedWorldPosition(target) ? target : null;
|
||||
}
|
||||
|
||||
/** Describe the camera as an orbit around the current viewport center. */
|
||||
export function readCameraTargetFrame(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const target = viewer?.trackedEntity
|
||||
? trackedTarget(viewer)
|
||||
: pickViewTarget(viewer);
|
||||
if (!camera || !target || !isPickedWorldPosition(camera.positionWC))
|
||||
return null;
|
||||
|
||||
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(target);
|
||||
const inverse = Cesium.Matrix4.inverseTransformation(
|
||||
transform,
|
||||
new Cesium.Matrix4(),
|
||||
);
|
||||
const localOffset = Cesium.Matrix4.multiplyByPoint(
|
||||
inverse,
|
||||
camera.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const range = Cesium.Cartesian3.magnitude(localOffset);
|
||||
if (!Number.isFinite(range) || range < 1) return null;
|
||||
|
||||
const pitch = -Math.asin(Cesium.Math.clamp(localOffset.z / range, -1, 1));
|
||||
// Cesium places a lookAt camera at range * (-sin h cos p, -cos h cos p,
|
||||
// -sin p) in the target's east-north-up frame, so recovering h means
|
||||
// negating BOTH horizontal components. Negating only the north one reads
|
||||
// every heading as its mirror image (30 deg comes back as 330), which would
|
||||
// make north-up rotate the wrong way from three quarters of the compass.
|
||||
const targetHeading = Math.atan2(-localOffset.x, -localOffset.y);
|
||||
const heading = normalizedHeading(
|
||||
pitch < Cesium.Math.toRadians(-88.5) ? camera.heading : targetHeading,
|
||||
);
|
||||
return { target, range, heading, pitch };
|
||||
}
|
||||
|
||||
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
|
||||
function setCameraTargetFrame(viewer, frame) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
|
||||
try {
|
||||
const trackedTransform = viewer.trackedEntity
|
||||
? Cesium.Matrix4.clone(camera.transform)
|
||||
: null;
|
||||
camera.lookAt(
|
||||
frame.target,
|
||||
new Cesium.HeadingPitchRange(
|
||||
normalizedHeading(frame.heading),
|
||||
frame.pitch,
|
||||
frame.range,
|
||||
),
|
||||
);
|
||||
if (trackedTransform) {
|
||||
// Preserve EntityView's reference frame (including satellite frames).
|
||||
// Its next update carries this new local offset along with the target.
|
||||
camera.lookAtTransform(trackedTransform);
|
||||
viewer.scene?.requestRender?.();
|
||||
return true;
|
||||
}
|
||||
const destination = Cesium.Cartesian3.clone(camera.positionWC);
|
||||
const direction = Cesium.Cartesian3.clone(camera.directionWC);
|
||||
const up = Cesium.Cartesian3.clone(camera.upWC);
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
if (destination && direction && up) {
|
||||
camera.setView({ destination, orientation: { direction, up } });
|
||||
}
|
||||
viewer.scene?.requestRender?.();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the map reads as tilted, from the orbit frame.
|
||||
*
|
||||
* This is the quantity the toggle commands, so it is the only one the button
|
||||
* may show. The camera's OWN pitch is a different angle: at a few kilometres
|
||||
* the two agree to 0.02 degrees, but from the full-globe view they are tens of
|
||||
* degrees apart, because the point under the middle of the screen is most of a
|
||||
* hemisphere away. Reading the camera's pitch there made the toggle choose
|
||||
* oblique twice in a row — commanded oblique, camera pitch came back near -77,
|
||||
* still "not tilted", tilt again.
|
||||
*
|
||||
* @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}.
|
||||
*/
|
||||
function frameIsTilted(frame) {
|
||||
const pitch = Number(frame?.pitch);
|
||||
return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false;
|
||||
}
|
||||
|
||||
/** Toggle between a straight-down map and a useful oblique map angle. */
|
||||
export function toggleCameraTilt(viewer) {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
if (!frame) return false;
|
||||
const tilted = frameIsTilted(frame);
|
||||
const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH;
|
||||
return setCameraTargetFrame(viewer, { ...frame, pitch })
|
||||
? { tilted: !tilted, pitch }
|
||||
: false;
|
||||
}
|
||||
|
||||
/** Rotate around the viewport center until north is at the top. */
|
||||
export function resetCameraNorth(viewer) {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
if (!frame) return false;
|
||||
return setCameraTargetFrame(viewer, { ...frame, heading: 0 });
|
||||
}
|
||||
|
||||
function headingDegrees(camera) {
|
||||
return Cesium.Math.toDegrees(normalizedHeading(camera?.heading));
|
||||
}
|
||||
|
||||
/** Ease an orbit without handing off the entity that owns the follow camera. */
|
||||
export function createCameraOrientationAnimator(
|
||||
viewer,
|
||||
{ now = () => performance.now(), duration = 650 } = {},
|
||||
) {
|
||||
let remove = null;
|
||||
let pending = null;
|
||||
const cancel = () => {
|
||||
remove?.();
|
||||
remove = null;
|
||||
pending = null;
|
||||
};
|
||||
function animate(frame, destination) {
|
||||
cancel();
|
||||
if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0)
|
||||
return setCameraTargetFrame(viewer, { ...frame, ...destination });
|
||||
const entity = viewer.trackedEntity;
|
||||
pending = destination;
|
||||
const start = now();
|
||||
const headingDelta = Cesium.Math.negativePiToPi(
|
||||
destination.heading - frame.heading,
|
||||
);
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const progress = Cesium.Math.clamp((now() - start) / duration, 0, 1);
|
||||
const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress);
|
||||
const target = entity ? trackedTarget(viewer) : frame.target;
|
||||
if (
|
||||
!isPickedWorldPosition(target) ||
|
||||
!setCameraTargetFrame(viewer, {
|
||||
...frame,
|
||||
target,
|
||||
heading: frame.heading + headingDelta * eased,
|
||||
pitch: Cesium.Math.lerp(frame.pitch, destination.pitch, eased),
|
||||
})
|
||||
) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (progress === 1) cancel();
|
||||
});
|
||||
viewer.scene.requestRender?.();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
animate,
|
||||
cancel,
|
||||
get destination() {
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer the two map-orientation actions and publish the state their buttons
|
||||
* show: whether the map is tilted and the camera's whole-degree heading.
|
||||
* The state is published on every rendered frame; its store keeps only a
|
||||
* change, so an owner that takes the buttons back shows its state at once.
|
||||
*/
|
||||
export function bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish,
|
||||
runNavigation,
|
||||
showToast,
|
||||
}) {
|
||||
const removers = [];
|
||||
let destroyed = false;
|
||||
// The tilt state is the one thing here that costs a depth read of the middle
|
||||
// of the screen: 4 ms median and up to 12 ms, measured on this machine. It is
|
||||
// therefore held between refreshes rather than recomputed per frame.
|
||||
let tilted = false;
|
||||
const animator = createCameraOrientationAnimator(viewer);
|
||||
|
||||
/** Recompute the tilt state exactly, paying for one pick. */
|
||||
const refreshTilt = () => {
|
||||
if (destroyed) return;
|
||||
tilted = frameIsTilted(readCameraTargetFrame(viewer));
|
||||
sync();
|
||||
};
|
||||
|
||||
/**
|
||||
* Publish the two controls' state.
|
||||
*
|
||||
* The compass needle reads the camera's own bearing, which costs nothing, so
|
||||
* this can run on every rendered frame: measured at 1.4 ms across 120 frames
|
||||
* of a continuously dragged map (0.012 ms per frame, 0.1 ms worst case),
|
||||
* against the 4 ms per frame the depth read it replaced would have cost.
|
||||
* That measured the controls' own work; what a publish renders is the
|
||||
* view's (the globe actions compass alone renders the heading).
|
||||
*/
|
||||
function sync() {
|
||||
if (destroyed) return;
|
||||
const next = {
|
||||
tilted,
|
||||
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
|
||||
};
|
||||
publish?.(next);
|
||||
}
|
||||
|
||||
function tilt() {
|
||||
if (destroyed) return;
|
||||
const pending = animator.destination;
|
||||
const result = runNavigation('camera', () => {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
if (!frame) return false;
|
||||
const nextTilted = !frameIsTilted(pending || frame);
|
||||
const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH;
|
||||
return animator.animate(frame, {
|
||||
heading: pending?.heading ?? frame.heading,
|
||||
pitch,
|
||||
})
|
||||
? { tilted: nextTilted, pitch }
|
||||
: false;
|
||||
});
|
||||
if (result) {
|
||||
showToast?.(result.tilted ? t('Tilted view') : t('Straight-down view'));
|
||||
// The action reports the state it just commanded, so the button can
|
||||
// follow it without a second pick.
|
||||
tilted = result.tilted;
|
||||
}
|
||||
sync();
|
||||
}
|
||||
|
||||
function northUp() {
|
||||
if (destroyed) return;
|
||||
const pending = animator.destination;
|
||||
const result = runNavigation('camera', () => {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
return (
|
||||
frame &&
|
||||
animator.animate(frame, {
|
||||
heading: 0,
|
||||
pitch: pending?.pitch ?? frame.pitch,
|
||||
})
|
||||
);
|
||||
});
|
||||
if (result) showToast?.(t('North up'));
|
||||
sync();
|
||||
}
|
||||
|
||||
const subscribe = (event, handler) => {
|
||||
const remove = event?.addEventListener?.(handler);
|
||||
if (typeof remove === 'function') removers.push(remove);
|
||||
};
|
||||
// The needle follows every frame the scene draws. `camera.changed` is NOT
|
||||
// enough: it only fires past `camera.percentageChanged`, so a rotation of a
|
||||
// few degrees leaves the needle pointing at the old bearing.
|
||||
subscribe(viewer?.scene?.preRender, sync);
|
||||
// The exact tilt state is refreshed once per gesture, not once per frame —
|
||||
// moveEnd fires when the camera stops, which is where the pick is affordable.
|
||||
subscribe(viewer?.camera?.moveEnd, refreshTilt);
|
||||
subscribe(viewer?.trackedEntityChanged, animator.cancel);
|
||||
for (const type of ['pointerdown', 'wheel']) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
canvas?.addEventListener?.(type, animator.cancel, { passive: true });
|
||||
removers.push(() => canvas?.removeEventListener?.(type, animator.cancel));
|
||||
}
|
||||
refreshTilt();
|
||||
|
||||
return {
|
||||
tilt,
|
||||
northUp,
|
||||
sync,
|
||||
refreshTilt,
|
||||
cancel: animator.cancel,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
animator.cancel();
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
OBLIQUE_PITCH,
|
||||
STRAIGHT_DOWN_PITCH,
|
||||
bindCameraOrientationControls,
|
||||
createCameraOrientationAnimator,
|
||||
pickViewTarget,
|
||||
readCameraTargetFrame,
|
||||
resetCameraNorth,
|
||||
toggleCameraTilt,
|
||||
} from './cameraOrientationControls.js';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
|
||||
function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
|
||||
const calls = [];
|
||||
const changed = new Cesium.Event();
|
||||
const surface = target || Cesium.Cartesian3.fromDegrees(0, 0, 0);
|
||||
const position = cameraPosition || Cesium.Cartesian3.fromDegrees(0, 0, 1_000);
|
||||
const camera = {
|
||||
positionWC: position,
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
heading: Cesium.Math.toRadians(90),
|
||||
changed,
|
||||
pickEllipsoid: () => surface,
|
||||
getPickRay: () => ({}),
|
||||
lookAt(targetValue, offset) {
|
||||
calls.push({ type: 'lookAt', target: targetValue, offset });
|
||||
},
|
||||
lookAtTransform(transform) {
|
||||
calls.push({ type: 'lookAtTransform', transform });
|
||||
},
|
||||
setView(view) {
|
||||
calls.push({ type: 'setView', view });
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
camera,
|
||||
scene: {
|
||||
canvas: { clientWidth: 1_200, clientHeight: 800 },
|
||||
pickPositionSupported: Boolean(pickPosition),
|
||||
pickPosition: pickPosition || (() => null),
|
||||
globe: { pick: () => surface },
|
||||
requestRender: () => calls.push({ type: 'requestRender' }),
|
||||
},
|
||||
};
|
||||
return { viewer, calls, target: surface };
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the controls publish as the globe actions store keeps it:
|
||||
* `state` is the latest, and `publishes` counts the changes.
|
||||
*/
|
||||
function recorder() {
|
||||
const bar = {
|
||||
state: null,
|
||||
publishes: 0,
|
||||
publish(state) {
|
||||
if (
|
||||
bar.state?.tilted === state.tilted &&
|
||||
bar.state?.heading === state.heading
|
||||
)
|
||||
return;
|
||||
bar.state = state;
|
||||
bar.publishes += 1;
|
||||
},
|
||||
};
|
||||
return bar;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real Cesium camera attached to a minimal offscreen scene.
|
||||
*
|
||||
* The fake above records what lookAt was asked for but never moves, so it
|
||||
* cannot show whether a frame written into the camera reads back as the same
|
||||
* frame. These cases need the production math on both sides.
|
||||
*/
|
||||
function createRealCamera() {
|
||||
const scene = {
|
||||
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
|
||||
drawingBufferWidth: 1200,
|
||||
drawingBufferHeight: 800,
|
||||
mapProjection: new Cesium.GeographicProjection(Cesium.Ellipsoid.WGS84),
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
|
||||
// setView converts a world-space direction/up pair into the local frame
|
||||
// only in 3D, and the camera derives heading/pitch the same way, so the
|
||||
// scene mode is load-bearing here rather than decoration.
|
||||
mode: Cesium.SceneMode.SCENE3D,
|
||||
pixelRatio: 1,
|
||||
};
|
||||
const camera = new Cesium.Camera(scene);
|
||||
scene.camera = camera;
|
||||
return { camera, scene };
|
||||
}
|
||||
|
||||
/** A viewer whose center pick is a fixed ground point and whose camera is real. */
|
||||
function createRealViewer(target) {
|
||||
const { camera, scene } = createRealCamera();
|
||||
scene.pickPositionSupported = false;
|
||||
scene.pickPosition = () => null;
|
||||
scene.globe.pick = () => target;
|
||||
scene.requestRender = () => {};
|
||||
// The binding drives the needle from the scene's per-frame signal, not from
|
||||
// `camera.changed`, so the fixture has to carry one.
|
||||
scene.preRender = new Cesium.Event();
|
||||
camera.getPickRay = () => ({});
|
||||
return { viewer: { camera, scene }, target };
|
||||
}
|
||||
|
||||
/** Place a real camera in an orbit around `target` and release the frame. */
|
||||
function orbit(viewer, target, headingDeg, pitchRad, range) {
|
||||
viewer.camera.lookAt(
|
||||
target,
|
||||
new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(headingDeg),
|
||||
pitchRad,
|
||||
range,
|
||||
),
|
||||
);
|
||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
}
|
||||
|
||||
const AUSTIN_GROUND = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 0);
|
||||
|
||||
test('a heading written into a real camera reads back as the same heading', () => {
|
||||
// One oblique heading per quadrant, plus the cardinals, at pitches a user
|
||||
// reaches with the tilt control.
|
||||
for (const headingDeg of [0, 30, 90, 135, 180, 225, 270, 315, 350]) {
|
||||
for (const pitchDeg of [-25, -40, -65]) {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
viewer.camera.lookAt(
|
||||
AUSTIN_GROUND,
|
||||
new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(headingDeg),
|
||||
Cesium.Math.toRadians(pitchDeg),
|
||||
4000,
|
||||
),
|
||||
);
|
||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
assert.ok(frame, `no frame at ${headingDeg}/${pitchDeg}`);
|
||||
const readDeg = Cesium.Math.toDegrees(frame.heading);
|
||||
assert.ok(
|
||||
Math.abs(((readDeg - headingDeg + 540) % 360) - 180) < 0.01,
|
||||
`heading ${headingDeg} deg at pitch ${pitchDeg} read back as ${readDeg.toFixed(2)} deg`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(frame.pitch) - pitchDeg) < 0.01,
|
||||
`pitch ${pitchDeg} read back as ${Cesium.Math.toDegrees(frame.pitch).toFixed(2)}`,
|
||||
);
|
||||
assert.ok(Math.abs(frame.range - 4000) < 1, `range ${frame.range}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('north-up really points a real camera north from every quadrant', () => {
|
||||
for (const headingDeg of [30, 135, 225, 315]) {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
viewer.camera.lookAt(
|
||||
AUSTIN_GROUND,
|
||||
new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(headingDeg),
|
||||
Cesium.Math.toRadians(-40),
|
||||
4000,
|
||||
),
|
||||
);
|
||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
|
||||
assert.equal(
|
||||
resetCameraNorth(viewer),
|
||||
true,
|
||||
`reset failed at ${headingDeg}`,
|
||||
);
|
||||
const after = readCameraTargetFrame(viewer);
|
||||
assert.ok(after, `no frame after reset at ${headingDeg}`);
|
||||
const readDeg = Cesium.Math.toDegrees(after.heading);
|
||||
assert.ok(
|
||||
Math.min(readDeg, 360 - readDeg) < 0.01,
|
||||
`north-up from ${headingDeg} deg left the camera at ${readDeg.toFixed(2)} deg`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(after.pitch) + 40) < 0.01,
|
||||
'north-up must not change the pitch',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(after.range - 4000) < 1,
|
||||
'north-up must not change the range',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('tilt moves a real camera between the two pitches and keeps target and range', () => {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
viewer.camera.lookAt(
|
||||
AUSTIN_GROUND,
|
||||
new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(215),
|
||||
STRAIGHT_DOWN_PITCH,
|
||||
3000,
|
||||
),
|
||||
);
|
||||
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
|
||||
const first = toggleCameraTilt(viewer);
|
||||
assert.deepEqual(first, { tilted: true, pitch: OBLIQUE_PITCH });
|
||||
const tilted = readCameraTargetFrame(viewer);
|
||||
assert.ok(Math.abs(tilted.pitch - OBLIQUE_PITCH) < 1e-6);
|
||||
assert.ok(Math.abs(tilted.range - 3000) < 1, `range ${tilted.range}`);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(tilted.heading) - 215) < 0.01,
|
||||
'tilt must not rotate the map',
|
||||
);
|
||||
|
||||
// Releasing the orbit frame must leave the camera pointing at the same
|
||||
// ground, not merely standing in the right place.
|
||||
const toTarget = Cesium.Cartesian3.normalize(
|
||||
Cesium.Cartesian3.subtract(
|
||||
AUSTIN_GROUND,
|
||||
viewer.camera.positionWC,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const offBy = Cesium.Math.toDegrees(
|
||||
Math.acos(
|
||||
Cesium.Math.clamp(
|
||||
Cesium.Cartesian3.dot(viewer.camera.directionWC, toTarget),
|
||||
-1,
|
||||
1,
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
offBy < 0.05,
|
||||
`camera points ${offBy.toFixed(2)} deg away from its target`,
|
||||
);
|
||||
|
||||
const second = toggleCameraTilt(viewer);
|
||||
assert.deepEqual(second, { tilted: false, pitch: STRAIGHT_DOWN_PITCH });
|
||||
const down = readCameraTargetFrame(viewer);
|
||||
assert.ok(Math.abs(down.pitch - STRAIGHT_DOWN_PITCH) < 1e-6);
|
||||
assert.ok(Math.abs(down.range - 3000) < 1);
|
||||
});
|
||||
|
||||
test('tilt alternates at globe range, not only at map range', () => {
|
||||
// The canonical full-globe view. The camera's own pitch here is tens of
|
||||
// degrees away from the orbit pitch, so a control reading the camera would
|
||||
// command oblique twice in a row and the map would never come back level.
|
||||
for (const range of [3_000, 250_000, 18_000_000]) {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range);
|
||||
|
||||
const first = toggleCameraTilt(viewer);
|
||||
assert.deepEqual(
|
||||
first,
|
||||
{ tilted: true, pitch: OBLIQUE_PITCH },
|
||||
`first click at ${range} m must tilt`,
|
||||
);
|
||||
const second = toggleCameraTilt(viewer);
|
||||
assert.deepEqual(
|
||||
second,
|
||||
{ tilted: false, pitch: STRAIGHT_DOWN_PITCH },
|
||||
`second click at ${range} m must level (camera pitch reads ${Cesium.Math.toDegrees(viewer.camera.pitch).toFixed(1)} deg)`,
|
||||
);
|
||||
const third = toggleCameraTilt(viewer);
|
||||
assert.deepEqual(
|
||||
third,
|
||||
{ tilted: true, pitch: OBLIQUE_PITCH },
|
||||
`third click at ${range} m must tilt again`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the button shows the state the next click will produce, at every range', () => {
|
||||
for (const range of [3_000, 18_000_000]) {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range);
|
||||
const bar = recorder();
|
||||
const controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish: bar.publish,
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
bar.state.tilted,
|
||||
false,
|
||||
`straight down at ${range} m must not read as tilted`,
|
||||
);
|
||||
controls.tilt();
|
||||
assert.equal(
|
||||
bar.state.tilted,
|
||||
true,
|
||||
`after one click at ${range} m the button must read tilted`,
|
||||
);
|
||||
// And the exact refresh that runs when the camera settles must agree.
|
||||
controls.refreshTilt();
|
||||
assert.equal(
|
||||
bar.state.tilted,
|
||||
true,
|
||||
`the settled state at ${range} m must agree with the commanded one`,
|
||||
);
|
||||
controls.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('the needle follows a rotation too small for the camera-changed threshold', () => {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
||||
const bar = recorder();
|
||||
const controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish: bar.publish,
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
});
|
||||
assert.equal(bar.state.heading, 0);
|
||||
|
||||
// Ten degrees is far below Cesium's default percentageChanged, so
|
||||
// `camera.changed` may never fire for it; the per-frame signal must.
|
||||
let changedFired = 0;
|
||||
viewer.camera.changed.addEventListener(() => {
|
||||
changedFired += 1;
|
||||
});
|
||||
orbit(viewer, AUSTIN_GROUND, 10, Cesium.Math.toRadians(-80), 5_000);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(
|
||||
bar.state.heading,
|
||||
10,
|
||||
`needle must follow a 10 degree rotation (camera.changed fired ${changedFired} time(s))`,
|
||||
);
|
||||
|
||||
orbit(viewer, AUSTIN_GROUND, 13, Cesium.Math.toRadians(-80), 5_000);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(bar.state.heading, 13);
|
||||
controls.destroy();
|
||||
});
|
||||
|
||||
test('north-up indicator follows real heading including the 360-degree seam', () => {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
const bar = recorder();
|
||||
const controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish: bar.publish,
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
});
|
||||
for (const [heading, active] of [
|
||||
[0, true],
|
||||
[15, false],
|
||||
[359.8, true],
|
||||
[359, false],
|
||||
[360, true],
|
||||
[90, false],
|
||||
]) {
|
||||
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(bar.state.heading === 0, active, `heading ${heading}`);
|
||||
}
|
||||
controls.destroy();
|
||||
});
|
||||
|
||||
test('a settled camera publishes no change, and teardown releases every signal', () => {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
||||
const bar = recorder();
|
||||
const preRenderBefore = viewer.scene.preRender.numberOfListeners;
|
||||
const moveEndBefore = viewer.camera.moveEnd.numberOfListeners;
|
||||
const controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish: bar.publish,
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
});
|
||||
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore + 1);
|
||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore + 1);
|
||||
|
||||
bar.publishes = 0;
|
||||
for (let i = 0; i < 60; i += 1) viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(
|
||||
bar.publishes,
|
||||
0,
|
||||
'60 frames of a settled camera publish no change',
|
||||
);
|
||||
|
||||
orbit(viewer, AUSTIN_GROUND, 47, Cesium.Math.toRadians(-80), 5_000);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.ok(bar.publishes > 0, 'a rotated camera updates the needle');
|
||||
bar.publishes = 0;
|
||||
for (let i = 0; i < 20; i += 1) viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(bar.publishes, 0, 'and then goes quiet again');
|
||||
|
||||
controls.destroy();
|
||||
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore);
|
||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore);
|
||||
bar.publishes = 0;
|
||||
orbit(viewer, AUSTIN_GROUND, 120, Cesium.Math.toRadians(-80), 5_000);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
controls.tilt();
|
||||
assert.equal(bar.publishes, 0, 'a destroyed binding publishes nothing');
|
||||
});
|
||||
|
||||
test('a sky-facing camera yields no frame and both actions decline', () => {
|
||||
const { camera, scene } = createRealCamera();
|
||||
scene.pickPositionSupported = false;
|
||||
scene.pickPosition = () => null;
|
||||
scene.globe.pick = () => undefined;
|
||||
scene.requestRender = () => {};
|
||||
camera.getPickRay = () => ({});
|
||||
camera.pickEllipsoid = () => undefined;
|
||||
const viewer = { camera, scene };
|
||||
|
||||
assert.equal(readCameraTargetFrame(viewer), null);
|
||||
assert.equal(toggleCameraTilt(viewer), false);
|
||||
assert.equal(resetCameraNorth(viewer), false);
|
||||
});
|
||||
|
||||
test('center picking prefers terrain over the ellipsoid and falls through an invalid depth hit', () => {
|
||||
const invalid = new Cesium.Cartesian3(Number.NaN, 0, 0);
|
||||
const terrain = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 400);
|
||||
const sea = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
|
||||
const { viewer } = createViewer({ pickPosition: () => invalid });
|
||||
viewer.scene.globe.pick = () => terrain;
|
||||
viewer.camera.pickEllipsoid = () => sea;
|
||||
assert.equal(
|
||||
pickViewTarget(viewer),
|
||||
terrain,
|
||||
'the rendered ground wins over sea level',
|
||||
);
|
||||
|
||||
viewer.scene.globe.pick = () => null;
|
||||
assert.equal(
|
||||
pickViewTarget(viewer),
|
||||
sea,
|
||||
'the ellipsoid still answers when nothing is under the cursor',
|
||||
);
|
||||
});
|
||||
|
||||
test('target frame identifies a straight-down camera in local ENU space', () => {
|
||||
const { viewer } = createViewer();
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
assert.ok(frame);
|
||||
assert.ok(Math.abs(frame.pitch - Cesium.Math.toRadians(-90)) < 0.001);
|
||||
assert.ok(frame.range > 999 && frame.range < 1_001);
|
||||
assert.equal(frame.heading, Cesium.Math.toRadians(90));
|
||||
});
|
||||
|
||||
test('tilt preserves the picked target and range while choosing oblique pitch', () => {
|
||||
const { viewer, calls, target } = createViewer();
|
||||
const before = readCameraTargetFrame(viewer);
|
||||
const result = toggleCameraTilt(viewer);
|
||||
const lookAt = calls.find((call) => call.type === 'lookAt');
|
||||
assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH });
|
||||
assert.equal(lookAt.target, target);
|
||||
assert.equal(lookAt.offset.range, before.range);
|
||||
assert.equal(lookAt.offset.pitch, OBLIQUE_PITCH);
|
||||
assert.ok(calls.some((call) => call.type === 'lookAtTransform'));
|
||||
assert.ok(calls.some((call) => call.type === 'setView'));
|
||||
});
|
||||
|
||||
test('north-up preserves target pitch and range', () => {
|
||||
const { viewer, calls } = createViewer();
|
||||
const before = readCameraTargetFrame(viewer);
|
||||
assert.equal(resetCameraNorth(viewer), true);
|
||||
const lookAt = calls.find((call) => call.type === 'lookAt');
|
||||
assert.equal(lookAt.offset.heading, 0);
|
||||
assert.equal(lookAt.offset.pitch, before.pitch);
|
||||
assert.equal(lookAt.offset.range, before.range);
|
||||
});
|
||||
|
||||
test('bindings route both controls and release every listener on destroy', () => {
|
||||
const { viewer } = createViewer();
|
||||
const bar = recorder();
|
||||
const navigations = [];
|
||||
const toasts = [];
|
||||
const controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish: bar.publish,
|
||||
runNavigation(noun, navigate) {
|
||||
navigations.push(noun);
|
||||
return navigate();
|
||||
},
|
||||
showToast: (message) => toasts.push(message),
|
||||
});
|
||||
|
||||
controls.tilt();
|
||||
controls.northUp();
|
||||
assert.deepEqual(navigations, ['camera', 'camera']);
|
||||
assert.deepEqual(toasts, ['Tilted view', 'North up']);
|
||||
// The button shows the state the click just commanded, not a re-measurement.
|
||||
assert.equal(bar.state.tilted, true);
|
||||
assert.equal(bar.state.heading, 90);
|
||||
|
||||
viewer.camera.heading = -1e-12;
|
||||
controls.sync();
|
||||
assert.equal(bar.state.heading, 0);
|
||||
|
||||
controls.destroy();
|
||||
controls.tilt();
|
||||
assert.deepEqual(navigations, ['camera', 'camera']);
|
||||
assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89));
|
||||
});
|
||||
|
||||
for (const [label, altitude, range] of [
|
||||
['aircraft', 10000, 1500],
|
||||
['satellite', 400000, 1500000],
|
||||
['high satellite', 35786000, 3000000],
|
||||
]) {
|
||||
test(`${label}: animated tilt and north retain a moving EntityView`, (t) => {
|
||||
// Use Cesium's built-in TEME fallback without fetching Earth-orientation data.
|
||||
t.mock.method(
|
||||
Cesium.Transforms,
|
||||
'computeFixedToIcrfMatrix',
|
||||
() => undefined,
|
||||
);
|
||||
let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude);
|
||||
const { viewer } = createRealViewer(target);
|
||||
viewer.scene.preUpdate = new Cesium.Event();
|
||||
viewer.clock = { currentTime: Cesium.JulianDate.now() };
|
||||
const entity = new Cesium.Entity({
|
||||
position: new Cesium.CallbackPositionProperty(() => target, false),
|
||||
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
|
||||
});
|
||||
entity.gevDisplayPosition = () => target;
|
||||
viewer.trackedEntity = entity;
|
||||
const follow = new Cesium.EntityView(entity, viewer.scene);
|
||||
follow.update(viewer.clock.currentTime);
|
||||
viewer.camera.lookAt(
|
||||
target,
|
||||
new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(350),
|
||||
OBLIQUE_PITCH,
|
||||
range,
|
||||
),
|
||||
);
|
||||
let time = 0;
|
||||
const animator = createCameraOrientationAnimator(viewer, {
|
||||
now: () => time,
|
||||
duration: 1000,
|
||||
});
|
||||
for (const pitch of [STRAIGHT_DOWN_PITCH, OBLIQUE_PITCH]) {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
animator.animate(frame, { heading: 0, pitch });
|
||||
for (const step of [0, 100, 500, 900, 1000]) {
|
||||
time = (pitch === STRAIGHT_DOWN_PITCH ? 0 : 1000) + step;
|
||||
target = Cesium.Cartesian3.fromDegrees(
|
||||
-97 + time / 100000,
|
||||
30,
|
||||
altitude,
|
||||
);
|
||||
follow.update(viewer.clock.currentTime);
|
||||
const transform = Cesium.Matrix4.clone(viewer.camera.transform);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.ok(
|
||||
Cesium.Matrix4.equalsEpsilon(
|
||||
viewer.camera.transform,
|
||||
transform,
|
||||
1e-8,
|
||||
),
|
||||
);
|
||||
const actual = readCameraTargetFrame(viewer);
|
||||
const eased = Cesium.EasingFunction.CUBIC_IN_OUT(step / 1000);
|
||||
assert.ok(
|
||||
Math.abs(actual.pitch - Cesium.Math.lerp(frame.pitch, pitch, eased)) <
|
||||
1e-6,
|
||||
);
|
||||
assert.ok(Math.abs(actual.range - range) < 1e-5);
|
||||
}
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
|
||||
}
|
||||
target = Cesium.Cartesian3.fromDegrees(-96, 31, altitude);
|
||||
follow.update(viewer.clock.currentTime);
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.ok(Math.abs(readCameraTargetFrame(viewer).range - range) < 1e-5);
|
||||
});
|
||||
}
|
||||
|
||||
test('orientation animation cancels on replacement and explicit cancellation', () => {
|
||||
const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0));
|
||||
orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000);
|
||||
viewer.scene.preUpdate = new Cesium.Event();
|
||||
const animator = createCameraOrientationAnimator(viewer);
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH });
|
||||
animator.animate(frame, { heading: 0, pitch: OBLIQUE_PITCH });
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
|
||||
viewer.trackedEntity = {};
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
|
||||
viewer.trackedEntity = undefined;
|
||||
animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH });
|
||||
animator.cancel();
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('orientation navigation preserves follow and selection; new navigation cancels animation', () => {
|
||||
const calls = [];
|
||||
const entity = {};
|
||||
const viewer = {
|
||||
trackedEntity: entity,
|
||||
camera: { cancelFlight: () => calls.push('cancel-flight') },
|
||||
};
|
||||
let cockpit = false;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: {},
|
||||
isCockpitActive: () => cockpit,
|
||||
cancelOrientation: () => calls.push('cancel-orientation'),
|
||||
clearLocation: () => calls.push('clear-location'),
|
||||
cancelShareSelection: () => calls.push('clear-selection'),
|
||||
interruptCameraMotion: () => calls.push('interrupt-motion'),
|
||||
stopOrbit: () => calls.push('stop-orbit'),
|
||||
showToast: () => calls.push('toast'),
|
||||
});
|
||||
assert.equal(
|
||||
navigation.runOrientation('camera', () => 'animated'),
|
||||
'animated',
|
||||
);
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.deepEqual(calls, [
|
||||
'cancel-orientation',
|
||||
'interrupt-motion',
|
||||
'stop-orbit',
|
||||
'cancel-flight',
|
||||
]);
|
||||
calls.length = 0;
|
||||
navigation._stampNavigation({ cancelPendingSelection: false });
|
||||
assert.deepEqual(calls, ['cancel-orientation', 'clear-location']);
|
||||
calls.length = 0;
|
||||
cockpit = true;
|
||||
assert.equal(
|
||||
navigation.runOrientation('camera', () =>
|
||||
assert.fail('cockpit should refuse'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(calls, ['toast']);
|
||||
});
|
||||
|
||||
test('rapid tilt presses reverse direction and north-up keeps the requested tilt', (t) => {
|
||||
let time = 0;
|
||||
t.mock.method(performance, 'now', () => time);
|
||||
const target = Cesium.Cartesian3.fromDegrees(0, 0);
|
||||
const { viewer } = createRealViewer(target);
|
||||
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
|
||||
viewer.scene.preUpdate = new Cesium.Event();
|
||||
const bar = recorder();
|
||||
let controls;
|
||||
controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
publish: bar.publish,
|
||||
runNavigation: (_noun, navigate) => {
|
||||
controls.cancel();
|
||||
return navigate();
|
||||
},
|
||||
});
|
||||
controls.tilt();
|
||||
controls.tilt();
|
||||
assert.equal(bar.state.tilted, true);
|
||||
controls.tilt();
|
||||
controls.northUp();
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
|
||||
time = 650;
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
assert.ok(Math.abs(frame.pitch - STRAIGHT_DOWN_PITCH) < 1e-6);
|
||||
assert.ok(Math.abs(frame.heading) < 1e-6);
|
||||
controls.destroy();
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { CctvControls } from './cctvControls.js';
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { useCctvStore } from '../stores/cctv.js';
|
||||
|
||||
// One click-to-type pose chip. The panel's delegated click starts an edit
|
||||
// in CctvControls, which publishes it; the chip then holds a number input
|
||||
// that ends the edit on Enter, blur or Escape.
|
||||
const props = defineProps({
|
||||
field: { type: String, required: true },
|
||||
/** Message for the label shown before the first render. */
|
||||
label: { type: String, required: true },
|
||||
});
|
||||
const { view, calEdit } = storeToRefs(useCctvStore());
|
||||
const edit = computed(() =>
|
||||
calEdit.value?.field === props.field ? calEdit.value : null,
|
||||
);
|
||||
|
||||
// The draft is the user's once the input exists: the starting value is set
|
||||
// on mount only, since re-renders would otherwise write it back.
|
||||
const vDraft = {
|
||||
mounted(el, { value }) {
|
||||
el.value = value;
|
||||
el.focus();
|
||||
el.select();
|
||||
},
|
||||
};
|
||||
|
||||
function onKeydown(event) {
|
||||
if (event.key === 'Enter') edit.value?.finish(true, event.target.value);
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
edit.value?.finish(false);
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onBlur(event) {
|
||||
edit.value?.finish(true, event.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="cctv-cal-value"
|
||||
:data-cal-field="field"
|
||||
:disabled="edit ? false : view?.calibration.disabled"
|
||||
><template v-if="edit"
|
||||
>{{ edit.label
|
||||
}}<input
|
||||
:key="edit.id"
|
||||
v-draft="edit.value"
|
||||
type="number"
|
||||
class="gev-field gev-field--inline cctv-cal-input"
|
||||
:step="edit.step"
|
||||
:aria-label="edit.ariaLabel"
|
||||
@keydown="onKeydown"
|
||||
@blur="onBlur"
|
||||
@click.stop /></template
|
||||
><template v-else>{{
|
||||
view?.calibration.values[field] ?? `${t(label)} --`
|
||||
}}</template></button
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import UiSelect from '../kit/UiSelect.js';
|
||||
import { useCctvStore } from '../stores/cctv.js';
|
||||
|
||||
// The camera picker: the cameras in view and a closing note for those the
|
||||
// view leaves out. It renders only when CctvControls publishes a picker,
|
||||
// which re-applies the active camera, as on every layer notification.
|
||||
const { picker } = storeToRefs(useCctvStore());
|
||||
|
||||
// The selection, applied once the options are in place.
|
||||
const pick = (el, { value: selection }) => {
|
||||
if (!selection) return;
|
||||
if ('value' in selection) {
|
||||
if (el.value !== selection.value) el.value = selection.value;
|
||||
} else if (el.selectedIndex !== selection.index)
|
||||
el.selectedIndex = selection.index;
|
||||
};
|
||||
const vPick = { mounted: pick, updated: pick };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiSelect v-pick="picker?.selection" :disabled="picker?.disabled"
|
||||
><option
|
||||
v-for="option in picker?.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>{{ option.label }}</option
|
||||
><option v-if="picker?.note" value="" disabled data-cctv-outside>{{
|
||||
picker.note
|
||||
}}</option></UiSelect
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { presentCctvSourceBadge } from './cctvFrames.js';
|
||||
import { presentCctvPanel } from './cctvPresentation.js';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
const IDLE = Object.freeze({ loading: false, error: false, hasFrame: false });
|
||||
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', () => {
|
||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||
const expect = (label, state, frame, active = camera, enabled = true) =>
|
||||
assert.deepEqual(presentCctvSourceBadge(active, enabled, frame), {
|
||||
label,
|
||||
state,
|
||||
});
|
||||
expect('SOURCE · UNKNOWN', 'idle', IDLE, camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', IDLE, null);
|
||||
expect('FRAME · LOADING', 'loading', { ...IDLE, loading: true });
|
||||
expect('FRAME · UNAVAILABLE', 'error', { ...IDLE, error: true });
|
||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||
const retained = { ...IDLE, error: true, hasFrame: true };
|
||||
expect('SNAPSHOT · DEGRADED', 'ready', retained);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready', { ...retained, loading: true });
|
||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', IDLE, {
|
||||
...camera,
|
||||
sourceStatus: status,
|
||||
});
|
||||
}
|
||||
expect('LIVE · OK', 'ready', IDLE, { feedType: 'live', sourceStatus: 'ok' });
|
||||
});
|
||||
|
||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', () => {
|
||||
const cases = [
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
['curated', false, 'CAL · CURATED'],
|
||||
['curated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['calibrated', false, 'CAL · CALIBRATED'],
|
||||
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
[null, false, 'CAL · --'],
|
||||
];
|
||||
for (const [calBadge, calDirty, label] of cases) {
|
||||
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
|
||||
const { quality } = presentCctvPanel(
|
||||
{ enabled: true, activeCameraId: 'a', activeCamera },
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(quality, {
|
||||
label,
|
||||
calBadge: calDirty ? 'edited' : calBadge || '',
|
||||
});
|
||||
}
|
||||
assert.deepEqual(presentCctvPanel(null, false).quality, {
|
||||
label: 'CAL · --',
|
||||
calBadge: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||
testContext.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const badge = presentCctvSourceBadge(
|
||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||
true,
|
||||
IDLE,
|
||||
);
|
||||
assert.equal(badge.label, `${t('SNAPSHOT')} · ${t('DEGRADED')}`);
|
||||
assert.notEqual(badge.label, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(badge.state, 'ready');
|
||||
const { quality } = presentCctvPanel(
|
||||
{
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(quality, {
|
||||
label: t('CAL · EDITED (UNSAVED)'),
|
||||
calBadge: 'edited',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
export function _initCctvPanel() {
|
||||
if (!this._cctvPanel) return;
|
||||
|
||||
this.listen(this._cctvEnableBtn, 'click', async () => {
|
||||
this._actionGeneration++;
|
||||
await this.actions.toggleEnabled();
|
||||
});
|
||||
|
||||
this.listen(this._cctvNearestBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
if (!(await this.actions.toggleEnabled(true))) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
generation !== this._actionGeneration ||
|
||||
!this.actions.isEnabled() ||
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => this.cctv.focusNearest({ focus: false }),
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.8),
|
||||
);
|
||||
});
|
||||
|
||||
this.listen(this._cctvPrevBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
if (!(await this.actions.toggleEnabled(true))) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
generation !== this._actionGeneration ||
|
||||
!this.actions.isEnabled() ||
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => this.cctv.cycleCamera(-1),
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.4),
|
||||
);
|
||||
});
|
||||
|
||||
this.listen(this._cctvNextBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
if (!(await this.actions.toggleEnabled(true))) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
generation !== this._actionGeneration ||
|
||||
!this.actions.isEnabled() ||
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => this.cctv.cycleCamera(1),
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.4),
|
||||
);
|
||||
});
|
||||
|
||||
// The picker defers rebuilds while focused; catch up once it lets go.
|
||||
this.listen(this._cctvSelect, 'blur', () => {
|
||||
this._picker = null;
|
||||
this._renderCctvState(this._cctvState);
|
||||
});
|
||||
|
||||
this.listen(this._cctvSelect, 'change', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
const cameraId = this._cctvSelect.value;
|
||||
if (!cameraId) return;
|
||||
if (!(await this.actions.toggleEnabled(true))) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
generation !== this._actionGeneration ||
|
||||
!this.actions.isEnabled() ||
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
// Picking a camera from the dropdown flies to it. The dropdown lists the
|
||||
// cameras in view (and the active camera, which may be far away), so a
|
||||
// bare selection could leave the view somewhere else.
|
||||
this.actions.runExplicitFocus(
|
||||
() => (this.cctv.selectCamera(cameraId) ? cameraId : null),
|
||||
(selectedId) => this.cctv.focusCamera(selectedId, 2.2),
|
||||
);
|
||||
this.actions.setParams({ selectedCameraId: cameraId }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvFocusBtn, 'click', async () => {
|
||||
const generation = ++this._actionGeneration;
|
||||
const activeId = this._cctvState?.activeCameraId;
|
||||
const selected = this._cctvState?.activeCameraId || this._cctvSelect?.value;
|
||||
if (!selected) return;
|
||||
if (!(await this.actions.toggleEnabled(true))) return;
|
||||
if (
|
||||
this.destroyed ||
|
||||
generation !== this._actionGeneration ||
|
||||
!this.actions.isEnabled() ||
|
||||
(activeId && activeId !== this._cctvState?.activeCameraId)
|
||||
)
|
||||
return;
|
||||
this.actions.runExplicitFocus(
|
||||
() => selected,
|
||||
(cameraId) => this.cctv.focusCamera(cameraId, 1.9),
|
||||
);
|
||||
this.actions.setParams({ selectedCameraId: selected }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvCoverageBtn, 'click', () => {
|
||||
const current =
|
||||
this._cctvState?.coverageMode ||
|
||||
(this._cctvState?.showCoverage ? 'on' : 'off');
|
||||
const next =
|
||||
current === 'off' ? 'on' : current === 'on' ? 'viewshed' : 'off';
|
||||
this.actions.setParams({ coverageMode: next }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvAutoHopBtn, 'click', () => {
|
||||
const current = !!this._cctvState?.autoHop;
|
||||
this.actions.setParams({ autoHop: !current }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvProjectionBtn, 'click', () => {
|
||||
const current = this._cctvState?.showProjection !== false;
|
||||
this.actions.setParams({ showProjection: !current }, { origin: 'user' });
|
||||
});
|
||||
|
||||
this.listen(this._cctvAdjustBtn, 'click', () => {
|
||||
const current = !!this._cctvState?.calibrationMode;
|
||||
this.actions.setParams({ calibrationMode: !current }, { origin: 'user' });
|
||||
});
|
||||
|
||||
// Click-to-edit pose readout: each chip swaps to a number input; Enter or
|
||||
// blur commits (converted to a calibration offset against basePose),
|
||||
// Escape cancels. Delegated so re-renders never re-bind.
|
||||
this.listen(this._cctvCalReadout, 'click', (event) => {
|
||||
const chip = event.target.closest?.('.cctv-cal-value');
|
||||
if (!chip || chip.disabled || chip.querySelector('input')) return;
|
||||
this._beginCctvCalValueEdit(chip);
|
||||
});
|
||||
|
||||
this.listen(this._cctvCalibSaveBtn, 'click', () => {
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
if (!cameraId || !this.actions.setParams) return;
|
||||
this.actions.setParams(
|
||||
{
|
||||
selectedCameraId: cameraId,
|
||||
calibration: { cameraId, save: true },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
this.actions.showToast(t('CCTV calibration saved'));
|
||||
});
|
||||
|
||||
this.listen(this._cctvCalibResetBtn, 'click', () => {
|
||||
this._resetCctvCalibration();
|
||||
});
|
||||
|
||||
this._renderCctvState(null);
|
||||
this.actions.syncViewport();
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
|
||||
/**
|
||||
* Field definitions for the CCTV click-to-edit pose readout. Chips DISPLAY the
|
||||
* camera's EFFECTIVE pose (not raw offsets — "HDG 135.0°" instead of the old
|
||||
* "HEADING 0°" nonsense); typed values convert back to calibration offsets
|
||||
* against the frozen basePose. ΔN/ΔE stay offset-denominated (absolute lat/lon
|
||||
* typing is user-hostile).
|
||||
*/
|
||||
const CCTV_CAL_FIELDS = {
|
||||
heading: {
|
||||
label: msg('HDG'),
|
||||
unit: '°',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.headingDeg,
|
||||
toPatch: (value, base) => ({
|
||||
headingDeg: signedNormalizeDeg(value - base.headingDeg),
|
||||
}),
|
||||
},
|
||||
pitch: {
|
||||
label: msg('PITCH'),
|
||||
unit: '°',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.pitchDeg,
|
||||
toPatch: (value, base) => ({ pitchDeg: value - base.pitchDeg }),
|
||||
},
|
||||
fov: {
|
||||
label: msg('FOV'),
|
||||
unit: '°',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.fovDeg,
|
||||
toPatch: (value, base) => ({ fovDeg: value - base.fovDeg }),
|
||||
},
|
||||
range: {
|
||||
label: msg('RANGE'),
|
||||
unit: 'm',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.rangeM,
|
||||
toPatch: (value, base) => ({
|
||||
rangeScale: base.rangeM > 0 ? value / base.rangeM : 1,
|
||||
}),
|
||||
},
|
||||
height: {
|
||||
label: msg('HGT'),
|
||||
unit: 'm',
|
||||
decimals: 0,
|
||||
get: (cam) => cam.mountHeightM,
|
||||
toPatch: (value, base) => ({ heightM: value - base.mountHeightM }),
|
||||
},
|
||||
north: {
|
||||
label: msg('ΔN'),
|
||||
unit: 'm',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetNorthM || 0,
|
||||
toPatch: (value) => ({ offsetNorthM: value }),
|
||||
},
|
||||
east: {
|
||||
label: msg('ΔE'),
|
||||
unit: 'm',
|
||||
decimals: 1,
|
||||
get: (cam) => cam.calibration?.offsetEastM || 0,
|
||||
toPatch: (value) => ({ offsetEastM: value }),
|
||||
},
|
||||
};
|
||||
|
||||
/** Chip text for one pose value: locale decimals, translated metre unit. */
|
||||
function calValueText(field, value) {
|
||||
const options = {
|
||||
minimumFractionDigits: field.decimals,
|
||||
maximumFractionDigits: field.decimals,
|
||||
};
|
||||
if (field.unit === 'm') {
|
||||
// English keeps its compact "RANGE 120m"; other languages space the unit.
|
||||
const metres = formatNumber(Number(value), {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
});
|
||||
return `${t(field.label)} ${isolate(t('{value}m', { value: metres }))}`;
|
||||
}
|
||||
const number = formatNumber(Number(value), {
|
||||
useGrouping: false,
|
||||
...options,
|
||||
});
|
||||
// Isolated so a minus sign stays on its number in right-to-left text.
|
||||
return `${t(field.label)} ${isolate(`${number}${field.unit}`)}`;
|
||||
}
|
||||
|
||||
export function _activeCctvCameraId() {
|
||||
return this._cctvState?.activeCameraId || this._cctvSelect?.value || '';
|
||||
}
|
||||
|
||||
export function _resetCctvCalibration() {
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
if (!cameraId || !this.actions.setParams) return;
|
||||
this.actions.setParams(
|
||||
{
|
||||
selectedCameraId: cameraId,
|
||||
calibration: {
|
||||
cameraId,
|
||||
reset: true,
|
||||
},
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
this.actions.showToast(t('CCTV calibration reset'));
|
||||
}
|
||||
|
||||
export function _beginCctvCalValueEdit(chip) {
|
||||
if (this.destroyed) return;
|
||||
this._calibrationEdit?.(false);
|
||||
const fieldId = chip.dataset.calField;
|
||||
const field = CCTV_CAL_FIELDS[fieldId];
|
||||
const activeCamera = this._cctvState?.activeCamera;
|
||||
if (!field || !activeCamera?.basePose) return;
|
||||
const cameraId = this._activeCctvCameraId();
|
||||
const basePose = { ...activeCamera.basePose };
|
||||
|
||||
let finished = false;
|
||||
/**
|
||||
* End the edit: commit converts the typed value to a calibration offset
|
||||
* against the pose captured when it began; anything else just closes it.
|
||||
*/
|
||||
const finish = (commit, typedText = '') => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
this._calibrationEdit = null;
|
||||
this.store.setCalEdit(null);
|
||||
const typed = parseFloat(typedText);
|
||||
if (
|
||||
commit &&
|
||||
!this.destroyed &&
|
||||
Number.isFinite(typed) &&
|
||||
this._cctvState?.enabled &&
|
||||
this.actions.isEnabled() &&
|
||||
cameraId &&
|
||||
cameraId === this._activeCctvCameraId() &&
|
||||
this.actions.setParams
|
||||
) {
|
||||
this.actions.setParams(
|
||||
{
|
||||
selectedCameraId: cameraId,
|
||||
calibration: { cameraId, patch: field.toPatch(typed, basePose) },
|
||||
},
|
||||
{ origin: 'user' },
|
||||
);
|
||||
}
|
||||
};
|
||||
this._calibrationEdit = finish;
|
||||
// The readout renders a number input in this chip; Enter or blur commits
|
||||
// and Escape cancels through `finish`.
|
||||
this.store.setCalEdit({
|
||||
field: fieldId,
|
||||
label: `${t(field.label)} `,
|
||||
ariaLabel: t(field.label),
|
||||
step: field.decimals > 0 ? '0.1' : '1',
|
||||
value: Number(field.get(activeCamera)).toFixed(field.decimals),
|
||||
finish,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The calibration block for a camera state: ADJUST, the pose chips and
|
||||
* whether calibration is possible. Chips show the effective pose.
|
||||
* @param {object|null} state Camera layer UI state.
|
||||
* @param {boolean} enabled Whether the layer is on.
|
||||
*/
|
||||
export function presentCctvCalibration(state, enabled) {
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
const canCalibrate = !!enabled && !!activeCamera;
|
||||
const adjustOn = !!state?.calibrationMode;
|
||||
const values = {};
|
||||
for (const [id, field] of Object.entries(CCTV_CAL_FIELDS)) {
|
||||
const value = canCalibrate ? field.get(activeCamera) : null;
|
||||
values[id] = Number.isFinite(value)
|
||||
? calValueText(field, value)
|
||||
: `${t(field.label)} --`;
|
||||
}
|
||||
return {
|
||||
disabled: !canCalibrate,
|
||||
adjust: {
|
||||
active: adjustOn && canCalibrate,
|
||||
text: adjustOn ? t('ADJUST ON') : t('ADJUST'),
|
||||
},
|
||||
values,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
EMPTY_CCTV_FRAME,
|
||||
_clearCctvFrame,
|
||||
_queueCctvFrame,
|
||||
_setCctvFrame,
|
||||
_settleCctvFrame,
|
||||
_syncCctvSourceBadge,
|
||||
} from './cctvFrames.js';
|
||||
import {
|
||||
_activeCctvCameraId,
|
||||
_resetCctvCalibration,
|
||||
_beginCctvCalValueEdit,
|
||||
} from './cctvCalibration.js';
|
||||
import {
|
||||
_calBadgeLabel,
|
||||
_renderCctvState,
|
||||
_publishCctvChip,
|
||||
_typeCctvSummary,
|
||||
_updateCctvSyncChip,
|
||||
} from './cctvPresentation.js';
|
||||
import { _initCctvPanel } from './cctvBindings.js';
|
||||
import { useCctvStore } from './stores/cctv.js';
|
||||
import { useStatusStore } from './stores/status.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
import { onLocaleChange } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Own camera-panel interaction and presentation; receive the camera port and
|
||||
* application actions. The panel view renders the buttons, CAL chip and meta
|
||||
* line from what this publishes (`gev-cctv` store), and the scene chrome the
|
||||
* camera grid chip (`gev-status` store).
|
||||
*/
|
||||
export class CctvControls {
|
||||
constructor({
|
||||
elements,
|
||||
cctv,
|
||||
actions,
|
||||
store = useCctvStore(applicationPinia()),
|
||||
statusStore = useStatusStore(applicationPinia()),
|
||||
}) {
|
||||
Object.assign(this, elements);
|
||||
this.store = store;
|
||||
this.statusStore = statusStore;
|
||||
this.cctv = cctv;
|
||||
this.actions = actions;
|
||||
this.destroyed = false;
|
||||
this.listeners = new AbortController();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._cctvState = null;
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._lastCctvSummaryText = '';
|
||||
this._lastSeenCctvActiveId = null;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = false;
|
||||
// The camera grid chip as last published; its label stays while hidden.
|
||||
this._cctvChip = null;
|
||||
this._cctvFrameRequestToken = 0;
|
||||
this._cctvFramePreloader = null;
|
||||
this._frame = EMPTY_CCTV_FRAME;
|
||||
this._calibrationEdit = null;
|
||||
this._actionGeneration = 0;
|
||||
this._initCctvPanel();
|
||||
// The panel and the grid chip hold translated text; show it again in a
|
||||
// new locale, from a fresh layer state when there is one (its summary is
|
||||
// worded by the layer).
|
||||
this._offLocale = onLocaleChange(() => {
|
||||
const state = this.cctv?.getUIState?.() ?? this._cctvState;
|
||||
if (state) this._renderCctvState(state);
|
||||
if (this._cctvChip) this._publishCctvChip({});
|
||||
});
|
||||
if (this._cctvVideo && typeof MutationObserver !== 'undefined') {
|
||||
this._videoVisibilityObserver = new MutationObserver(() =>
|
||||
this._renderCctvState(this._cctvState),
|
||||
);
|
||||
if (this._cctvPanel)
|
||||
this._videoVisibilityObserver.observe(this._cctvPanel, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'hidden'],
|
||||
});
|
||||
this.listen(document, 'visibilitychange', () =>
|
||||
this._renderCctvState(this._cctvState),
|
||||
);
|
||||
}
|
||||
}
|
||||
listen(target, type, handler, options = {}) {
|
||||
target?.addEventListener(type, handler, {
|
||||
...options,
|
||||
signal: this.listeners.signal,
|
||||
});
|
||||
}
|
||||
getState() {
|
||||
return this._cctvState;
|
||||
}
|
||||
connect() {
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
if (this.destroyed) return;
|
||||
this._cctvUnsubscribe = this.cctv.subscribe?.((state) =>
|
||||
this._renderCctvState(state),
|
||||
);
|
||||
if (this.cctv.getUIState) this._renderCctvState(this.cctv.getUIState());
|
||||
}
|
||||
_clearCctvFrame(...args) {
|
||||
return _clearCctvFrame.call(this, ...args);
|
||||
}
|
||||
_setCctvFrame(...args) {
|
||||
return _setCctvFrame.call(this, ...args);
|
||||
}
|
||||
_queueCctvFrame(...args) {
|
||||
return _queueCctvFrame.call(this, ...args);
|
||||
}
|
||||
_settleCctvFrame(...args) {
|
||||
return _settleCctvFrame.call(this, ...args);
|
||||
}
|
||||
_syncCctvSourceBadge(...args) {
|
||||
return _syncCctvSourceBadge.call(this, ...args);
|
||||
}
|
||||
_activeCctvCameraId(...args) {
|
||||
return _activeCctvCameraId.call(this, ...args);
|
||||
}
|
||||
_resetCctvCalibration(...args) {
|
||||
return _resetCctvCalibration.call(this, ...args);
|
||||
}
|
||||
_beginCctvCalValueEdit(...args) {
|
||||
return _beginCctvCalValueEdit.call(this, ...args);
|
||||
}
|
||||
_calBadgeLabel(...args) {
|
||||
return _calBadgeLabel.call(this, ...args);
|
||||
}
|
||||
_renderCctvState(...args) {
|
||||
return _renderCctvState.call(this, ...args);
|
||||
}
|
||||
_typeCctvSummary(...args) {
|
||||
return _typeCctvSummary.call(this, ...args);
|
||||
}
|
||||
_publishCctvChip(...args) {
|
||||
return _publishCctvChip.call(this, ...args);
|
||||
}
|
||||
_updateCctvSyncChip(...args) {
|
||||
return _updateCctvSyncChip.call(this, ...args);
|
||||
}
|
||||
_initCctvPanel(...args) {
|
||||
return _initCctvPanel.call(this, ...args);
|
||||
}
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._offLocale();
|
||||
this._cctvVideoSurface?.stop();
|
||||
this._videoVisibilityObserver?.disconnect();
|
||||
this._actionGeneration++;
|
||||
this.listeners.abort();
|
||||
this._cctvUnsubscribe?.();
|
||||
this._cctvUnsubscribe = null;
|
||||
this._calibrationEdit?.(false);
|
||||
this._clearCctvFrame();
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._publishCctvChip({ visible: false, stopped: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
|
||||
function element() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
dataset: {},
|
||||
src: '',
|
||||
removeAttribute(name) {
|
||||
if (name === 'src') this.src = '';
|
||||
},
|
||||
classList: {
|
||||
add(...values) {
|
||||
values.forEach((value) => classes.add(value));
|
||||
},
|
||||
remove(...values) {
|
||||
values.forEach((value) => classes.delete(value));
|
||||
},
|
||||
contains(value) {
|
||||
return classes.has(value);
|
||||
},
|
||||
toggle(value, enabled) {
|
||||
if (enabled) classes.add(value);
|
||||
else classes.delete(value);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(t) {
|
||||
const prior = globalThis.Image;
|
||||
const requests = [];
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
requests.push(this);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.Image = prior;
|
||||
});
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvFrame: element(), _cctvFrameWrap: element() },
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, requests };
|
||||
}
|
||||
|
||||
test('a late image completion cannot replace a newer camera preview', (t) => {
|
||||
const { controls, requests } = fixture(t);
|
||||
controls._queueCctvFrame('first.jpg', 'a', true);
|
||||
const stale = requests[0].onload;
|
||||
controls._queueCctvFrame('second.jpg', 'b', true);
|
||||
assert.equal(requests[0].onload, null);
|
||||
requests[1].onload();
|
||||
stale();
|
||||
assert.equal(controls._frame.src, 'second.jpg');
|
||||
assert.equal(controls._frame.cameraId, 'b');
|
||||
});
|
||||
|
||||
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
|
||||
const { controls, requests } = fixture(t);
|
||||
controls._queueCctvFrame('first.jpg', 'a', true);
|
||||
requests[0].onload();
|
||||
controls._queueCctvFrame('refresh.jpg', 'a', false);
|
||||
requests[1].onerror();
|
||||
assert.equal(controls._frame.src, 'first.jpg');
|
||||
assert.equal(controls._frame.hasFrame, true);
|
||||
controls._queueCctvFrame('other.jpg', 'b', true);
|
||||
assert.equal(controls._frame.src, '');
|
||||
assert.equal(controls._frame.hasFrame, false);
|
||||
});
|
||||
|
||||
test('destroy invalidates image callbacks and releases each subscription once', (t) => {
|
||||
const { controls, requests } = fixture(t);
|
||||
let unsubscribed = 0;
|
||||
controls.cctv.subscribe = () => () => {
|
||||
unsubscribed++;
|
||||
};
|
||||
controls.connect();
|
||||
controls.connect();
|
||||
assert.equal(unsubscribed, 1);
|
||||
controls._queueCctvFrame('first.jpg', 'a', true);
|
||||
const late = requests[0].onload;
|
||||
controls.destroy();
|
||||
controls.destroy();
|
||||
controls.connect();
|
||||
late();
|
||||
assert.equal(unsubscribed, 2);
|
||||
assert.equal(requests[0].onload, null);
|
||||
assert.equal(requests[0].onerror, null);
|
||||
assert.equal(controls._frame.src, '');
|
||||
controls._queueCctvFrame('late.jpg', 'b', true);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('disposing during camera enable prevents the delayed focus and future clicks', async () => {
|
||||
const button = new EventTarget();
|
||||
let resolveEnable;
|
||||
let enables = 0;
|
||||
let focuses = 0;
|
||||
const controls = new CctvControls({
|
||||
elements: { _cctvPanel: {}, _cctvNextBtn: button },
|
||||
cctv: {},
|
||||
actions: {
|
||||
isEnabled: () => true,
|
||||
syncViewport() {},
|
||||
toggleEnabled() {
|
||||
enables++;
|
||||
return new Promise((resolve) => {
|
||||
resolveEnable = resolve;
|
||||
});
|
||||
},
|
||||
runExplicitFocus() {
|
||||
focuses++;
|
||||
},
|
||||
},
|
||||
});
|
||||
button.dispatchEvent(new Event('click'));
|
||||
controls.destroy();
|
||||
resolveEnable(true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
button.dispatchEvent(new Event('click'));
|
||||
assert.equal(enables, 1);
|
||||
assert.equal(focuses, 0);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Display text for the CCTV source health the server reports
|
||||
* (server/providers/cctv.js `setHealth`). The server keeps its English codes
|
||||
* and messages; the panel translates them where it renders.
|
||||
*/
|
||||
|
||||
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'),
|
||||
});
|
||||
|
||||
const SOURCE_STATUS_LABELS = Object.freeze({
|
||||
ok: msg('OK'),
|
||||
degraded: msg('DEGRADED'),
|
||||
error: msg('ERROR'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
});
|
||||
|
||||
const SOURCE_MESSAGES = Object.freeze({
|
||||
'Live HLS connected': msg('Live HLS connected'),
|
||||
'Live HLS unavailable': msg('Live HLS unavailable'),
|
||||
'No stream URL configured': msg('No stream URL configured'),
|
||||
'Live stream connected': msg('Live stream connected'),
|
||||
'Snapshot feed connected': msg('Snapshot feed connected'),
|
||||
'Media fetch failed': msg('Media fetch failed'),
|
||||
'Upstream snapshot active': msg('Upstream snapshot active'),
|
||||
'Fallback Street View frame': msg('Fallback Street View frame'),
|
||||
'Upstream unavailable': msg('Upstream unavailable'),
|
||||
'No source configured': msg('No source configured'),
|
||||
});
|
||||
|
||||
const SOURCE_LABELS = Object.freeze({
|
||||
'Configured source': msg('Configured source'),
|
||||
'Configured Source': msg('Configured Source'),
|
||||
'Synthetic fallback': msg('Synthetic fallback'),
|
||||
});
|
||||
|
||||
/** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */
|
||||
function cctvSourceKindLabel(kind) {
|
||||
const key = String(kind || 'unknown').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
|
||||
export function cctvSourceStatusLabel(status) {
|
||||
const key = String(status || 'unknown').toLowerCase();
|
||||
return SOURCE_STATUS_LABELS[key]
|
||||
? t(SOURCE_STATUS_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** A server health message in the page language; unknown text passes through. */
|
||||
export function cctvSourceMessageText(message) {
|
||||
const text = String(message || '');
|
||||
if (SOURCE_MESSAGES[text]) return t(SOURCE_MESSAGES[text]);
|
||||
const http = /^Upstream HTTP (\d{3})$/.exec(text);
|
||||
if (http) return t('Upstream HTTP {status}', { status: http[1] });
|
||||
const media = /^Unexpected media type (.+)$/.exec(text);
|
||||
if (media) return t('Unexpected media type {type}', { type: media[1] });
|
||||
return text;
|
||||
}
|
||||
|
||||
/** A generic provider label from the server; real provider names pass through. */
|
||||
export function cctvSourceLabelText(label) {
|
||||
const text = String(label || '');
|
||||
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
|
||||
}
|
||||
|
||||
/** The preview before any frame: nothing requested, nothing shown. */
|
||||
export const EMPTY_CCTV_FRAME = Object.freeze({
|
||||
cameraId: '',
|
||||
currentSrc: '',
|
||||
src: '',
|
||||
loading: false,
|
||||
error: false,
|
||||
hasFrame: false,
|
||||
wrapLoading: false,
|
||||
});
|
||||
|
||||
/** Apply a change to the preview and publish it to the panel view. */
|
||||
export function _setCctvFrame(change) {
|
||||
this._frame = { ...this._frame, ...change };
|
||||
this.store.setFrame(this._frame);
|
||||
}
|
||||
|
||||
export function _clearCctvFrame() {
|
||||
this._cctvFrameRequestToken += 1;
|
||||
if (this._cctvFramePreloader) {
|
||||
this._cctvFramePreloader.onload = null;
|
||||
this._cctvFramePreloader.onerror = null;
|
||||
}
|
||||
this._cctvFramePreloader = null;
|
||||
if (this._cctvFrame) this._setCctvFrame(EMPTY_CCTV_FRAME);
|
||||
}
|
||||
|
||||
export function _queueCctvFrame(src, cameraId, cameraChanged) {
|
||||
if (this.destroyed || !this._cctvFrame || !src) return;
|
||||
|
||||
if (this._cctvFramePreloader) {
|
||||
this._cctvFramePreloader.onload = null;
|
||||
this._cctvFramePreloader.onerror = null;
|
||||
}
|
||||
const token = ++this._cctvFrameRequestToken;
|
||||
// A different camera gets an honest acquisition state. Never retain the
|
||||
// prior camera's pixels under the newly selected metadata.
|
||||
const hasFrame = cameraChanged ? false : this._frame.hasFrame;
|
||||
this._setCctvFrame({
|
||||
...(cameraChanged ? { src: '' } : {}),
|
||||
hasFrame,
|
||||
cameraId,
|
||||
currentSrc: src,
|
||||
loading: true,
|
||||
error: false,
|
||||
wrapLoading: !hasFrame,
|
||||
});
|
||||
|
||||
const preloader = new Image();
|
||||
this._cctvFramePreloader = preloader;
|
||||
preloader.onload = () => this._settleCctvFrame(token, src, true);
|
||||
preloader.onerror = () => this._settleCctvFrame(token, src, false);
|
||||
preloader.src = src;
|
||||
}
|
||||
|
||||
export function _settleCctvFrame(token, src, ok) {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this._cctvFrame ||
|
||||
token !== this._cctvFrameRequestToken
|
||||
)
|
||||
return;
|
||||
if (this._cctvFramePreloader) {
|
||||
this._cctvFramePreloader.onload = null;
|
||||
this._cctvFramePreloader.onerror = null;
|
||||
}
|
||||
this._cctvFramePreloader = null;
|
||||
// A failure leaves a settled frame on screen.
|
||||
this._setCctvFrame(
|
||||
ok
|
||||
? {
|
||||
loading: false,
|
||||
wrapLoading: false,
|
||||
error: false,
|
||||
src,
|
||||
hasFrame: true,
|
||||
}
|
||||
: { loading: false, wrapLoading: false, error: true },
|
||||
);
|
||||
this._syncCctvSourceBadge(
|
||||
this._cctvState?.activeCamera,
|
||||
!!this._cctvState?.enabled && !!this.actions.isEnabled(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The source badge's label and frame state for the camera and preview. */
|
||||
export function presentCctvSourceBadge(activeCamera, enabled, frame) {
|
||||
if (!enabled || !activeCamera)
|
||||
return { label: t('SOURCE · UNKNOWN'), state: 'idle' };
|
||||
if (frame.loading && !frame.hasFrame)
|
||||
return { label: t('FRAME · LOADING'), state: 'loading' };
|
||||
if (frame.error && !frame.hasFrame)
|
||||
return { label: t('FRAME · UNAVAILABLE'), state: 'error' };
|
||||
const kind = cctvSourceKindLabel(
|
||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||
);
|
||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||
return { label: `${kind} · ${status}`, state: 'ready' };
|
||||
}
|
||||
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
this.store.setSourceBadge(
|
||||
presentCctvSourceBadge(activeCamera, enabled, this._frame),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSameNode } from '../testSupport/domAssert.mjs';
|
||||
import test from 'node:test';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { presentCctvPanel } from './cctvPresentation.js';
|
||||
import { useCctvStore } from './stores/cctv.js';
|
||||
import CctvPanel from './views/panels/CctvPanel.vue';
|
||||
|
||||
const BUTTONS = {
|
||||
_cctvEnableBtn: 'cctv-enable-btn',
|
||||
_cctvNearestBtn: 'cctv-nearest-btn',
|
||||
_cctvPrevBtn: 'cctv-prev-btn',
|
||||
_cctvNextBtn: 'cctv-next-btn',
|
||||
_cctvFocusBtn: 'cctv-focus-btn',
|
||||
_cctvCoverageBtn: 'cctv-coverage-btn',
|
||||
_cctvAutoHopBtn: 'cctv-auto-hop-btn',
|
||||
_cctvProjectionBtn: 'cctv-projection-btn',
|
||||
};
|
||||
|
||||
function panel(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CctvPanel).use(pinia);
|
||||
app.mount(host);
|
||||
const elements = Object.fromEntries(
|
||||
Object.entries(BUTTONS).map(([key, id]) => [
|
||||
key,
|
||||
document.getElementById(id),
|
||||
]),
|
||||
);
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
...elements,
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
|
||||
_cctvFrame: document.getElementById('cctv-frame'),
|
||||
_cctvFrameWrap: document.getElementById('cctv-frame-wrap'),
|
||||
_cctvSummary: document.getElementById('cctv-summary'),
|
||||
_cctvCalReadout: document.getElementById('cctv-cal-readout'),
|
||||
_cctvAdjustBtn: document.getElementById('cctv-adjust-btn'),
|
||||
_cctvCalibSaveBtn: document.getElementById('cctv-calib-save-btn'),
|
||||
_cctvCalibResetBtn: document.getElementById('cctv-calib-reset-btn'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: {
|
||||
isEnabled: () => true,
|
||||
setPanelCollapsed() {},
|
||||
syncViewport() {},
|
||||
},
|
||||
store: useCctvStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const byId = (id) => document.getElementById(id);
|
||||
return { controls, byId };
|
||||
}
|
||||
|
||||
const camera = {
|
||||
id: 'a',
|
||||
city: 'Austin',
|
||||
name: 'Congress Ave',
|
||||
headingDeg: 91.6,
|
||||
fovDeg: 60,
|
||||
rangeM: 120.4,
|
||||
sourceLabel: 'Configured Source',
|
||||
calBadge: 'curated',
|
||||
};
|
||||
|
||||
test('the panel keeps its initial markup until the controls render', (t) => {
|
||||
const { byId } = panel(t);
|
||||
assert.equal(byId('cctv-enable-btn').textContent, 'CCTV OFF');
|
||||
assert.equal(byId('cctv-enable-btn').classList.contains('active'), false);
|
||||
assert.equal(byId('cctv-coverage-btn').textContent, 'COVERAGE OFF');
|
||||
assert.equal(byId('cctv-auto-hop-btn').textContent, 'AUTO HOP OFF');
|
||||
assert.equal(byId('cctv-projection-btn').textContent, 'PROJECTION ON');
|
||||
assert.equal(byId('cctv-quality-chip').textContent, 'CAL · --');
|
||||
assert.equal(byId('cctv-quality-chip').hasAttribute('data-cal-badge'), false);
|
||||
assert.equal(
|
||||
byId('cctv-meta').textContent,
|
||||
'Enable CCTV to load camera intersections',
|
||||
);
|
||||
for (const id of Object.values(BUTTONS))
|
||||
assert.equal(byId(id).disabled, false, id);
|
||||
});
|
||||
|
||||
test('a disabled layer disables every button but the switch', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] });
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-enable-btn').disabled, false);
|
||||
for (const [key, id] of Object.entries(BUTTONS)) {
|
||||
if (key === '_cctvEnableBtn') continue;
|
||||
assert.equal(byId(id).disabled, true, id);
|
||||
}
|
||||
assert.equal(
|
||||
byId('cctv-meta').textContent,
|
||||
'3 cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
});
|
||||
|
||||
test('an active camera renders the switch, toggles, CAL chip and meta line', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: camera,
|
||||
coverageMode: 'viewshed',
|
||||
autoHop: true,
|
||||
showProjection: false,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-enable-btn').textContent, 'CCTV ON');
|
||||
assert.equal(byId('cctv-enable-btn').classList.contains('active'), true);
|
||||
for (const id of Object.values(BUTTONS))
|
||||
assert.equal(byId(id).disabled, false, id);
|
||||
assert.equal(byId('cctv-coverage-btn').textContent, 'VIEWSHED ON');
|
||||
assert.equal(byId('cctv-coverage-btn').classList.contains('active'), true);
|
||||
assert.equal(byId('cctv-auto-hop-btn').textContent, 'AUTO HOP ON');
|
||||
assert.equal(byId('cctv-projection-btn').textContent, 'PROJECTION OFF');
|
||||
assert.equal(byId('cctv-projection-btn').classList.contains('active'), false);
|
||||
// The toggles say their state to assistive technology, not only by colour.
|
||||
const pressed = (id) => byId(id).getAttribute('aria-pressed');
|
||||
assert.equal(pressed('cctv-enable-btn'), 'true');
|
||||
assert.equal(pressed('cctv-coverage-btn'), 'true');
|
||||
assert.equal(pressed('cctv-auto-hop-btn'), 'true');
|
||||
assert.equal(pressed('cctv-projection-btn'), 'false');
|
||||
assert.equal(pressed('cctv-adjust-btn'), 'false');
|
||||
assert.equal(byId('cctv-quality-chip').textContent, 'CAL · CURATED');
|
||||
assert.equal(byId('cctv-quality-chip').dataset.calBadge, 'curated');
|
||||
assert.equal(
|
||||
byId('cctv-meta').textContent,
|
||||
'Austin · HDG 92° · FOV 60° · RANGE 120m · OFF · CURATED · Configured Source',
|
||||
);
|
||||
});
|
||||
|
||||
test('the buttons stay the elements the controls listen on', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
const before = Object.values(BUTTONS).map(byId);
|
||||
controls._renderCctvState({ enabled: true, count: 0, cameraIndex: [] });
|
||||
await nextTick();
|
||||
controls._renderCctvState({ enabled: false, count: 0, cameraIndex: [] });
|
||||
await nextTick();
|
||||
const after = Object.values(BUTTONS).map(byId);
|
||||
assert.ok(
|
||||
before.every((node, index) => node === after[index]),
|
||||
'a button was replaced',
|
||||
);
|
||||
assert.equal(byId('cctv-nearest-btn').disabled, true);
|
||||
});
|
||||
|
||||
test('FOCUS waits for an active camera while the other buttons work', () => {
|
||||
const loaded = { enabled: true, count: 2, cameraIndex: [] };
|
||||
const view = presentCctvPanel(loaded, true);
|
||||
assert.equal(view.navDisabled, false);
|
||||
assert.equal(view.focusDisabled, true);
|
||||
assert.equal(view.toggleDisabled, false);
|
||||
const active = presentCctvPanel(
|
||||
{ ...loaded, activeCameraId: 'a', activeCamera: camera },
|
||||
true,
|
||||
);
|
||||
assert.equal(active.focusDisabled, false);
|
||||
assert.equal(presentCctvPanel(loaded, false).toggleDisabled, true);
|
||||
});
|
||||
|
||||
test('coverage reads its mode, or the older on/off flag', () => {
|
||||
const text = (state) => presentCctvPanel(state, true).coverage;
|
||||
assert.deepEqual(text({ coverageMode: 'on' }), {
|
||||
active: true,
|
||||
text: 'COVERAGE ON',
|
||||
});
|
||||
assert.deepEqual(text({ showCoverage: true }), {
|
||||
active: true,
|
||||
text: 'COVERAGE ON',
|
||||
});
|
||||
assert.deepEqual(text({}), { active: false, text: 'COVERAGE OFF' });
|
||||
});
|
||||
|
||||
test('losing the active camera resets the CAL chip', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: camera,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-quality-chip').dataset.calBadge, 'curated');
|
||||
controls._renderCctvState(null);
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-quality-chip').textContent, 'CAL · --');
|
||||
assert.equal(byId('cctv-quality-chip').getAttribute('data-cal-badge'), '');
|
||||
assert.equal(
|
||||
byId('cctv-meta').textContent,
|
||||
'Enable CCTV to load camera intersections',
|
||||
);
|
||||
});
|
||||
|
||||
/** A panel with an active camera, calibration patches recorded. */
|
||||
async function calibrating(t) {
|
||||
const page = panel(t);
|
||||
const patches = [];
|
||||
page.controls.actions.setParams = (params) => patches.push(params);
|
||||
page.controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { ...camera, headingDeg: 30, basePose: { headingDeg: 20 } },
|
||||
});
|
||||
await nextTick();
|
||||
const chip = document.querySelector('[data-cal-field="heading"]');
|
||||
const begin = async () => {
|
||||
chip.click();
|
||||
await nextTick();
|
||||
return chip.querySelector('input');
|
||||
};
|
||||
const key = (name) =>
|
||||
new KeyboardEvent('keydown', { key: name, cancelable: true });
|
||||
return { ...page, patches, chip, begin, key };
|
||||
}
|
||||
|
||||
test('a pose chip types against the camera base captured when it opened', async (t) => {
|
||||
const { controls, patches, chip, begin, key } = await calibrating(t);
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
const input = await begin();
|
||||
assert.ok(input, 'the chip holds an input');
|
||||
assert.ok(document.activeElement === input, 'the input has focus');
|
||||
assert.equal(chip.textContent, 'HDG ');
|
||||
assert.equal(input.value, '30.0');
|
||||
assert.equal(input.step, '0.1');
|
||||
assert.equal(input.getAttribute('aria-label'), 'HDG');
|
||||
input.value = '100';
|
||||
// A refresh while typing keeps the draft and the base the edit opened with.
|
||||
controls._renderCctvState({
|
||||
...controls._cctvState,
|
||||
activeCamera: {
|
||||
...controls._cctvState.activeCamera,
|
||||
basePose: { headingDeg: 60 },
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
assertSameNode(chip.querySelector('input'), input, 'the input survives');
|
||||
assert.equal(input.value, '100');
|
||||
input.dispatchEvent(key('Enter'));
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, [
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', patch: { headingDeg: 80 } },
|
||||
},
|
||||
]);
|
||||
assert.equal(controls._calibrationEdit, null);
|
||||
await nextTick();
|
||||
assert.ok(!chip.querySelector('input'), 'the input is gone');
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
});
|
||||
|
||||
test('a camera switch cancels calibration before old blur can change the new camera', async (t) => {
|
||||
const { controls, patches, chip, begin } = await calibrating(t);
|
||||
const input = await begin();
|
||||
input.value = '100';
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: {
|
||||
...camera,
|
||||
id: 'b',
|
||||
headingDeg: 200,
|
||||
basePose: { headingDeg: 190 },
|
||||
},
|
||||
});
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
await nextTick();
|
||||
assert.deepEqual(patches, []);
|
||||
assert.ok(!chip.querySelector('input'), 'the input is gone');
|
||||
assert.equal(chip.textContent, 'HDG 200.0°');
|
||||
});
|
||||
|
||||
test('Escape claims calibration cancellation and disposal prevents late commits', async (t) => {
|
||||
const { controls, patches, chip, begin, key } = await calibrating(t);
|
||||
let input = await begin();
|
||||
input.value = '100';
|
||||
const escape = key('Escape');
|
||||
input.dispatchEvent(escape);
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
await nextTick();
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
input = await begin();
|
||||
input.value = '120';
|
||||
controls.destroy();
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
await nextTick();
|
||||
assert.ok(!chip.querySelector('input'), 'disposal closes the editor');
|
||||
});
|
||||
|
||||
test('ADJUST, SAVE and RESET follow whether a camera can be calibrated', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
|
||||
await nextTick();
|
||||
for (const id of [
|
||||
'cctv-adjust-btn',
|
||||
'cctv-calib-save-btn',
|
||||
'cctv-calib-reset-btn',
|
||||
])
|
||||
assert.equal(byId(id).disabled, true, id);
|
||||
const chip = document.querySelector('[data-cal-field="north"]');
|
||||
assert.equal(chip.textContent, 'ΔN --');
|
||||
assert.equal(chip.disabled, true);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: camera,
|
||||
calibrationMode: true,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-adjust-btn').textContent, 'ADJUST ON');
|
||||
assert.equal(byId('cctv-adjust-btn').classList.contains('active'), true);
|
||||
assert.equal(byId('cctv-calib-save-btn').disabled, false);
|
||||
assert.equal(chip.textContent, 'ΔN 0.0m');
|
||||
assert.equal(chip.disabled, false);
|
||||
});
|
||||
|
||||
test('blur alone commits the draft; an empty draft changes nothing', async (t) => {
|
||||
const { patches, chip, begin } = await calibrating(t);
|
||||
let input = await begin();
|
||||
input.value = '';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, []);
|
||||
await nextTick();
|
||||
assert.equal(chip.textContent, 'HDG 30.0°');
|
||||
input = await begin();
|
||||
input.value = '25';
|
||||
input.dispatchEvent(new Event('blur'));
|
||||
assert.deepEqual(patches, [
|
||||
{
|
||||
selectedCameraId: 'a',
|
||||
calibration: { cameraId: 'a', patch: { headingDeg: 5 } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('keys typed into a pose chip stay in it', async (t) => {
|
||||
const { begin } = await calibrating(t);
|
||||
const input = await begin();
|
||||
let reached = 0;
|
||||
document.body.addEventListener('keydown', () => reached++);
|
||||
for (const key of ['5', 'Escape'])
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
assert.equal(reached, 0);
|
||||
});
|
||||
|
||||
test('a second edit of the same chip gets a fresh input', async (t) => {
|
||||
const { controls, chip, begin } = await calibrating(t);
|
||||
const first = await begin();
|
||||
first.value = '99';
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
controls._beginCctvCalValueEdit(chip);
|
||||
await nextTick();
|
||||
const second = chip.querySelector('input');
|
||||
assert.ok(second && second !== first, 'a new input');
|
||||
assert.equal(second.value, '30.0');
|
||||
});
|
||||
|
||||
test('the frame preview renders each step of a camera frame request', async (t) => {
|
||||
const prior = globalThis.Image;
|
||||
const requests = [];
|
||||
globalThis.Image = class {
|
||||
constructor() {
|
||||
requests.push(this);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.Image = prior;
|
||||
});
|
||||
const { controls, byId } = panel(t);
|
||||
const frame = byId('cctv-frame');
|
||||
const wrap = byId('cctv-frame-wrap');
|
||||
const badge = byId('cctv-source-badge');
|
||||
const state = {
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: {
|
||||
...camera,
|
||||
frameUrl: 'a.jpg',
|
||||
sourceKind: 'snapshot',
|
||||
sourceStatus: 'ok',
|
||||
},
|
||||
};
|
||||
controls._renderCctvState(state);
|
||||
await nextTick();
|
||||
assert.equal(frame.dataset.cameraId, 'a');
|
||||
assert.equal(frame.dataset.loading, 'true');
|
||||
assert.equal(frame.hasAttribute('src'), false);
|
||||
assert.equal(wrap.classList.contains('loading'), true);
|
||||
assert.equal(badge.textContent, 'FRAME · LOADING');
|
||||
assert.equal(badge.dataset.frameState, 'loading');
|
||||
requests[0].onload();
|
||||
await nextTick();
|
||||
assert.equal(frame.getAttribute('src'), 'a.jpg');
|
||||
assert.equal(frame.dataset.loading, '');
|
||||
assert.equal(frame.classList.contains('active'), true);
|
||||
assert.equal(wrap.classList.contains('has-frame'), true);
|
||||
assert.equal(wrap.classList.contains('loading'), false);
|
||||
assert.equal(badge.textContent, 'SNAPSHOT · OK');
|
||||
// A failed refresh keeps the settled pixels.
|
||||
const refreshed = {
|
||||
...state,
|
||||
activeCamera: { ...state.activeCamera, frameUrl: 'a2.jpg' },
|
||||
};
|
||||
controls._renderCctvState(refreshed);
|
||||
requests[1].onerror();
|
||||
await nextTick();
|
||||
assert.equal(frame.getAttribute('src'), 'a.jpg');
|
||||
assert.equal(frame.dataset.error, 'true');
|
||||
assert.equal(wrap.classList.contains('has-frame'), true);
|
||||
assert.equal(badge.textContent, 'SNAPSHOT · OK');
|
||||
// Another camera clears them while its frame loads.
|
||||
controls._renderCctvState({
|
||||
...state,
|
||||
activeCameraId: 'b',
|
||||
activeCamera: { ...state.activeCamera, id: 'b', frameUrl: 'b.jpg' },
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(frame.hasAttribute('src'), false);
|
||||
assert.equal(frame.dataset.cameraId, 'b');
|
||||
assert.equal(wrap.classList.contains('has-frame'), false);
|
||||
assert.equal(badge.textContent, 'FRAME · LOADING');
|
||||
controls._renderCctvState({ ...state, enabled: false });
|
||||
await nextTick();
|
||||
assert.equal(frame.hasAttribute('src'), false);
|
||||
assert.equal(frame.dataset.cameraId, '');
|
||||
assert.equal(wrap.classList.contains('has-frame'), false);
|
||||
assert.equal(badge.textContent, 'SOURCE · UNKNOWN');
|
||||
assert.equal(badge.dataset.frameState, 'idle');
|
||||
});
|
||||
|
||||
test('a live camera swaps the still frame for the video canvas', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
assert.equal(byId('cctv-video').hidden, true);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { ...camera, isVideo: true },
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-video').hidden, false);
|
||||
assert.equal(byId('cctv-frame').hidden, true);
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [camera],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: camera,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-video').hidden, true);
|
||||
assert.equal(byId('cctv-frame').hidden, false);
|
||||
});
|
||||
|
||||
test('the scene summary types in, then holds the whole text', async (t) => {
|
||||
const { controls, byId } = panel(t);
|
||||
const text = 'Austin CCTV · 5th St / Congress Ave';
|
||||
controls._typeCctvSummary(text);
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-summary').textContent, '');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await nextTick();
|
||||
const partial = byId('cctv-summary').textContent;
|
||||
assert.ok(partial.length > 0 && partial.length < text.length, partial);
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
await nextTick();
|
||||
assert.equal(byId('cctv-summary').textContent, text);
|
||||
});
|
||||
|
||||
test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => {
|
||||
const meta = (headingDeg) => {
|
||||
const camera = {
|
||||
id: 'a',
|
||||
city: 'Austin',
|
||||
headingDeg,
|
||||
fovDeg: 74,
|
||||
rangeM: 700,
|
||||
};
|
||||
return presentCctvPanel(
|
||||
{
|
||||
enabled: true,
|
||||
activeCameraId: camera.id,
|
||||
activeCamera: camera,
|
||||
cameras: [camera],
|
||||
},
|
||||
true,
|
||||
).meta;
|
||||
};
|
||||
assert.match(meta(359.4), /· HDG 359° ·/);
|
||||
assert.match(meta(359.6), /· HDG 0° ·/);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
// src/ui/cctvPicker.test.mjs — the camera picker lists the cameras in view and
|
||||
// says how many more the view leaves out.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { useCctvStore } from './stores/cctv.js';
|
||||
import CctvPanel from './views/panels/CctvPanel.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CctvPanel).use(pinia);
|
||||
app.mount(host);
|
||||
const select = document.getElementById('cctv-camera-select');
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvPanel: document.getElementById('cctv-panel'),
|
||||
_cctvSelect: select,
|
||||
},
|
||||
cctv: {},
|
||||
actions: {
|
||||
isEnabled: () => true,
|
||||
setPanelCollapsed() {},
|
||||
syncViewport() {},
|
||||
},
|
||||
store: useCctvStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const render = async (state) => {
|
||||
controls._renderCctvState(state);
|
||||
await nextTick();
|
||||
};
|
||||
const values = () => [...select.options].map((option) => option.value);
|
||||
return { controls, select, render, values };
|
||||
}
|
||||
|
||||
const camera = (id) => ({ id, city: 'Austin', name: `Cam ${id}` });
|
||||
|
||||
test('the picker closes with the count of cameras outside the view', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 40,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(values(), ['a', 'b', '']);
|
||||
assert.equal(select.options[0].textContent, 'Austin · Cam a');
|
||||
const note = select.options[2];
|
||||
assert.equal(note.disabled, true);
|
||||
assert.equal(note.dataset.cctvOutside, '');
|
||||
assert.equal(
|
||||
note.textContent,
|
||||
'38 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.selectedIndex, -1, 'nothing active, nothing picked');
|
||||
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [camera('a'), camera('b')],
|
||||
});
|
||||
assert.deepEqual(
|
||||
values(),
|
||||
['a', 'b'],
|
||||
'a view holding every camera has no note',
|
||||
);
|
||||
});
|
||||
|
||||
test('with no camera in view, the closed picker shows the note', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
await render({ enabled: true, count: 1, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 1);
|
||||
assert.equal(
|
||||
select.options[select.selectedIndex].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, '');
|
||||
});
|
||||
|
||||
test('a disabled layer has no note', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
await render({ enabled: false, count: 40, cameraIndex: [] });
|
||||
assert.equal(select.options.length, 0);
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
assert.equal(select.disabled, true);
|
||||
});
|
||||
|
||||
test('the active camera is the one picked', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [a, b],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.equal(select.value, 'b');
|
||||
assert.equal(select.disabled, false);
|
||||
});
|
||||
|
||||
test('a camera activated under a focused picker counts against the note', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
const a = camera('a');
|
||||
const b = camera('b');
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [a],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
assert.equal(
|
||||
select.options[select.options.length - 1].textContent,
|
||||
'2 more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
select.focus();
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 3,
|
||||
cameraIndex: [b, a],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(values(), ['b', 'a', '']);
|
||||
assert.equal(
|
||||
select.options[select.options.length - 1].textContent,
|
||||
'1 more camera outside this view · pan or zoom out',
|
||||
);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('a focused picker that empties is disabled and shows the note', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
await render({ enabled: true, count: 3, cameraIndex: [camera('a')] });
|
||||
select.focus();
|
||||
await render({ enabled: true, count: 3, cameraIndex: [] });
|
||||
assert.equal(select.disabled, true);
|
||||
assert.deepEqual(values(), ['']);
|
||||
assert.equal(select.selectedIndex, 0, 'no stale camera is picked');
|
||||
});
|
||||
|
||||
test('a focused picker keeps its options until it lets go', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
await render({ enabled: true, count: 3, cameraIndex: [camera('a')] });
|
||||
select.focus();
|
||||
await render({ enabled: true, count: 3, cameraIndex: [camera('b')] });
|
||||
assert.deepEqual(values(), ['a', '']);
|
||||
select.blur();
|
||||
await nextTick();
|
||||
assert.deepEqual(values(), ['b', '']);
|
||||
});
|
||||
|
||||
test('losing the active camera clears the pick', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
const a = camera('a');
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 1,
|
||||
cameraIndex: [a],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
assert.equal(select.value, 'a');
|
||||
await render({ enabled: true, count: 1, cameraIndex: [a] });
|
||||
assert.equal(select.selectedIndex, -1);
|
||||
});
|
||||
|
||||
test('the last camera outside the view, added under focus, drops the note', async (t) => {
|
||||
const { select, render, values } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({ enabled: true, count: 2, cameraIndex: [a] });
|
||||
assert.deepEqual(values(), ['a', '']);
|
||||
select.focus();
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [b],
|
||||
activeCameraId: 'b',
|
||||
activeCamera: b,
|
||||
});
|
||||
assert.deepEqual(values(), ['b', 'a']);
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
|
||||
test('other panel updates leave the picker alone', async (t) => {
|
||||
const { controls, select, render } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [a, b],
|
||||
activeCameraId: 'a',
|
||||
activeCamera: a,
|
||||
});
|
||||
const before = [...select.options];
|
||||
// The user arrows to another camera while its activation is pending.
|
||||
select.value = 'b';
|
||||
controls.store.setSummary('Typing…');
|
||||
controls.store.setSourceBadge({ label: 'LIVE · OK', state: 'ready' });
|
||||
await nextTick();
|
||||
assert.equal(select.value, 'b');
|
||||
assert.ok(
|
||||
[...select.options].every((option, index) => option === before[index]),
|
||||
'the options are the same elements',
|
||||
);
|
||||
});
|
||||
|
||||
test('an active id without its camera record leaves the pick alone', async (t) => {
|
||||
const { select, render } = fixture(t);
|
||||
const [a, b] = [camera('a'), camera('b')];
|
||||
await render({ enabled: true, count: 2, cameraIndex: [a, b] });
|
||||
select.value = 'b';
|
||||
await render({
|
||||
enabled: true,
|
||||
count: 2,
|
||||
cameraIndex: [a, b],
|
||||
activeCameraId: 'a',
|
||||
});
|
||||
assert.equal(select.value, 'b');
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import { getLocale, keepCodesWithValues, msg, t, tn } from '../i18n/index.js';
|
||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
import { presentCctvCalibration } from './cctvCalibration.js';
|
||||
import { roundHeadingDegrees } from '../data/headingDegrees.js';
|
||||
export function _calBadgeLabel(badge) {
|
||||
switch (badge) {
|
||||
case 'calibrated':
|
||||
return t('CALIBRATED');
|
||||
case 'curated':
|
||||
return t('CURATED');
|
||||
case 'raw-prior':
|
||||
return t('RAW PRIOR');
|
||||
default:
|
||||
return '--';
|
||||
}
|
||||
}
|
||||
|
||||
/** A picker option for a camera. */
|
||||
function pickerOption(camera, id = camera.id) {
|
||||
return { value: id, label: `${camera.city} · ${camera.name}` };
|
||||
}
|
||||
|
||||
/** The picker's closing line for the cameras the view leaves out. */
|
||||
function outsideNoteText(count) {
|
||||
return tn(
|
||||
count,
|
||||
'{count} more camera outside this view · pan or zoom out',
|
||||
'{count} more cameras outside this view · pan or zoom out',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's buttons, CAL chip and meta line for a camera state, as the CCTV
|
||||
* panel view renders them. Pure: the picker, frame, source badge, calibration
|
||||
* readout and summary stay with the controls.
|
||||
* @param {object|null} state Camera layer UI state.
|
||||
* @param {boolean} enabled Whether the layer is on.
|
||||
*/
|
||||
export function presentCctvPanel(state, enabled) {
|
||||
const cameras = state?.cameraIndex || state?.cameras || [];
|
||||
const catalogCount = Number.isFinite(state?.count)
|
||||
? state.count
|
||||
: cameras.length;
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed
|
||||
// (color-coded volumes). The click handler cycles; this renders.
|
||||
const coverage = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
|
||||
const autoHop = !!state?.autoHop;
|
||||
const showProjection = state?.showProjection !== false;
|
||||
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only, no
|
||||
// in-world tint): three states from cctv.js's deriveCalBadge. Unsaved live
|
||||
// edits show EDITED over the persisted state (viewshed design §3e): SAVE
|
||||
// CAL promotes to CALIBRATED, RESET CAL clears.
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
return {
|
||||
enabled,
|
||||
navDisabled: !enabled || catalogCount === 0,
|
||||
focusDisabled: !enabled || catalogCount === 0 || !activeId,
|
||||
toggleDisabled: !enabled,
|
||||
coverage: {
|
||||
active: coverage !== 'off',
|
||||
text:
|
||||
coverage === 'viewshed'
|
||||
? t('VIEWSHED ON')
|
||||
: coverage === 'on'
|
||||
? t('COVERAGE ON')
|
||||
: t('COVERAGE OFF'),
|
||||
},
|
||||
autoHop: {
|
||||
active: autoHop,
|
||||
text: autoHop ? t('AUTO HOP ON') : t('AUTO HOP OFF'),
|
||||
},
|
||||
projection: {
|
||||
active: showProjection,
|
||||
text: showProjection ? t('PROJECTION ON') : t('PROJECTION OFF'),
|
||||
},
|
||||
quality: {
|
||||
label: dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', { badge: _calBadgeLabel(badge) }),
|
||||
calBadge: dirty ? 'edited' : badge || '',
|
||||
},
|
||||
meta: metaText(state, enabled, activeCamera, catalogCount),
|
||||
calibration: presentCctvCalibration(state, enabled),
|
||||
};
|
||||
}
|
||||
|
||||
function metaText(state, enabled, activeCamera, catalogCount) {
|
||||
if (activeCamera) {
|
||||
const provider = cctvSourceLabelText(
|
||||
activeCamera.sourceLabel || activeCamera.provider || 'Configured Source',
|
||||
);
|
||||
const statusMsg = activeCamera.sourceMessage
|
||||
? ` · ${cctvSourceMessageText(activeCamera.sourceMessage)}`
|
||||
: '';
|
||||
// A partner-supplied feed inside a pack names its owner here.
|
||||
const credit = activeCamera.credit ? ` · ${activeCamera.credit}` : '';
|
||||
const calBadge = activeCamera.calBadge
|
||||
? _calBadgeLabel(activeCamera.calBadge)
|
||||
: '';
|
||||
const projLabel = state?.showProjection !== false ? t('MONITOR') : t('OFF');
|
||||
return keepCodesWithValues(
|
||||
t(
|
||||
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
|
||||
{
|
||||
city: activeCamera.city,
|
||||
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
|
||||
fov: `${Math.round(activeCamera.fovDeg)}°`,
|
||||
range: Math.round(activeCamera.rangeM),
|
||||
projection: projLabel,
|
||||
calBadge: calBadge ? ` · ${calBadge}` : '',
|
||||
provider,
|
||||
status: `${credit}${statusMsg}`,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
if (catalogCount > 0)
|
||||
return enabled
|
||||
? tn(
|
||||
catalogCount,
|
||||
'{count} camera loaded · click a camera to activate',
|
||||
'{count} cameras loaded · click a camera to activate',
|
||||
)
|
||||
: tn(
|
||||
catalogCount,
|
||||
'{count} camera loaded · enable CCTV to activate',
|
||||
'{count} cameras loaded · enable CCTV to activate',
|
||||
);
|
||||
return t('Enable CCTV to load camera intersections');
|
||||
}
|
||||
|
||||
export function _renderCctvState(state) {
|
||||
if (this.destroyed) return;
|
||||
if (
|
||||
!state?.enabled ||
|
||||
!this.actions.isEnabled() ||
|
||||
state?.activeCameraId !== this._cctvState?.activeCameraId
|
||||
) {
|
||||
this._calibrationEdit?.(false);
|
||||
}
|
||||
this._cctvState = state || null;
|
||||
// The light index, not `state.cameras`: reading that builds every camera's
|
||||
// full state, which a large catalog cannot afford on each notification.
|
||||
const cameras = state?.cameraIndex || state?.cameras || [];
|
||||
// The picker lists what is in view; the catalog is every camera.
|
||||
const catalogCount = Number.isFinite(state?.count)
|
||||
? state.count
|
||||
: cameras.length;
|
||||
const enabled = !!state?.enabled && !!this.actions.isEnabled();
|
||||
const activeId = state?.activeCameraId || '';
|
||||
const activeCamera = state?.activeCamera || null;
|
||||
|
||||
// Auto-expand the panel when the active camera CHANGES to a new non-null
|
||||
// id while the layer is enabled. Covers click-on-globe, panel controls,
|
||||
// and voice (selectCamera/cycleCamera/focusNearest all notify through
|
||||
// this subscription). The last-seen guard keeps routine notifications
|
||||
// from re-expanding a panel the user deliberately collapsed, and timed
|
||||
// auto-hop transitions only expand on the first activation so the panel
|
||||
// does not pop open on every hop.
|
||||
const effectiveActiveId = enabled ? activeId || null : null;
|
||||
const isFirstActivation = this._lastSeenCctvActiveId === null;
|
||||
if (
|
||||
effectiveActiveId &&
|
||||
effectiveActiveId !== this._lastSeenCctvActiveId &&
|
||||
(!state?.autoHop || isFirstActivation)
|
||||
) {
|
||||
this.actions.setPanelCollapsed('cctv-panel', false, {
|
||||
explicit: Boolean(state?.explicitSelection),
|
||||
});
|
||||
}
|
||||
this._lastSeenCctvActiveId = effectiveActiveId;
|
||||
|
||||
this._updateCctvSyncChip(state?.loading, enabled);
|
||||
|
||||
if (this._cctvSelect) {
|
||||
// A focused picker keeps the options it shows; blur re-renders it. One
|
||||
// about to be disabled cannot hold focus, so it does not count.
|
||||
const disabled = !enabled || cameras.length === 0;
|
||||
const picking = document.activeElement === this._cctvSelect && !disabled;
|
||||
// Cameras the view leaves out get one closing line, so a short list
|
||||
// does not read as the whole catalog. A disabled layer has no view.
|
||||
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
|
||||
let picker = this._picker;
|
||||
// The index keeps its identity until the view or the active camera
|
||||
// changes, so an unchanged list keeps its options; a new locale words
|
||||
// the closing line again.
|
||||
if (
|
||||
!picking &&
|
||||
(picker?.source !== cameras ||
|
||||
picker.outside !== outside ||
|
||||
picker.locale !== getLocale())
|
||||
)
|
||||
picker = {
|
||||
source: cameras,
|
||||
outside,
|
||||
locale: getLocale(),
|
||||
options: cameras.map((camera) => pickerOption(camera)),
|
||||
note: outside ? outsideNoteText(outside) : '',
|
||||
};
|
||||
else if (
|
||||
picking &&
|
||||
activeId &&
|
||||
activeCamera &&
|
||||
!picker?.options.some((option) => option.value === activeId)
|
||||
) {
|
||||
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
|
||||
// voice); add it so the picker does not go blank. It is one fewer
|
||||
// outside the view.
|
||||
const options = [
|
||||
pickerOption(activeCamera, activeId),
|
||||
...(picker?.options || []),
|
||||
];
|
||||
const left = Math.max(0, catalogCount - options.length);
|
||||
picker = {
|
||||
...picker,
|
||||
options,
|
||||
note: picker?.note && left ? outsideNoteText(left) : '',
|
||||
};
|
||||
}
|
||||
this._picker = picker;
|
||||
this.store.setPicker({
|
||||
options: picker?.options || [],
|
||||
note: picker?.note || '',
|
||||
disabled,
|
||||
// The active camera is always one of the listed records. With nothing
|
||||
// active the closed picker shows the note when nothing is in view; a
|
||||
// focused picker keeps its old options, so it only clears.
|
||||
selection:
|
||||
activeId && activeCamera
|
||||
? { value: activeId }
|
||||
: !activeId
|
||||
? { index: !picking && cameras.length === 0 && outside ? 0 : -1 }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
this.store.publish(presentCctvPanel(state, enabled));
|
||||
|
||||
const liveIntent = enabled && !!activeCamera?.isVideo;
|
||||
this.store.setLive(liveIntent);
|
||||
if (this._cctvVideo) {
|
||||
const visible =
|
||||
liveIntent &&
|
||||
!document.hidden &&
|
||||
!this._cctvPanel?.classList.contains('collapsed');
|
||||
if (!visible || this._cctvVideoCameraId !== activeId) {
|
||||
this._cctvVideoSurface?.stop();
|
||||
this._cctvVideoSurface = null;
|
||||
}
|
||||
this._cctvVideoCameraId = activeId;
|
||||
if (visible && !this._cctvVideoSurface) {
|
||||
this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () =>
|
||||
this.cctv.getActiveVideoElement?.(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (this._cctvFrame && !liveIntent) {
|
||||
const nextSrc = enabled ? activeCamera?.frameUrl : null;
|
||||
const nextCameraId = enabled ? activeCamera?.id || '' : '';
|
||||
const cameraChanged = this._frame.cameraId !== nextCameraId;
|
||||
const frameLoading = this._frame.loading;
|
||||
// A same-camera refresh waits for the current image to settle. Replacing
|
||||
// src every 10 seconds can cancel a slow but healthy decode forever and
|
||||
// leave SNAPSHOT · OK beside a blank/loading preview. Camera changes are
|
||||
// immediate so navigation never waits on the prior camera's request.
|
||||
if (
|
||||
nextSrc &&
|
||||
(cameraChanged || (!frameLoading && this._frame.currentSrc !== nextSrc))
|
||||
) {
|
||||
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
|
||||
}
|
||||
if (!nextSrc) {
|
||||
this._clearCctvFrame();
|
||||
}
|
||||
} else if (liveIntent) {
|
||||
this._clearCctvFrame();
|
||||
}
|
||||
|
||||
this._syncCctvSourceBadge(activeCamera, enabled);
|
||||
this._typeCctvSummary(
|
||||
state?.summary ||
|
||||
t('Enable CCTV to start camera-linked intelligence summaries.'),
|
||||
);
|
||||
}
|
||||
|
||||
export function _typeCctvSummary(text) {
|
||||
if (this.destroyed || !this._cctvSummary) return;
|
||||
const nextText = String(text || '').trim() || t('No summary available.');
|
||||
if (nextText === this._lastCctvSummaryText) return;
|
||||
this._lastCctvSummaryText = nextText;
|
||||
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
this.store.setSummary('');
|
||||
let idx = 0;
|
||||
this._cctvSummaryTypingTimer = setInterval(() => {
|
||||
if (this.destroyed) return;
|
||||
idx += 3;
|
||||
if (idx >= nextText.length) {
|
||||
this.store.setSummary(nextText);
|
||||
clearInterval(this._cctvSummaryTypingTimer);
|
||||
this._cctvSummaryTypingTimer = null;
|
||||
return;
|
||||
}
|
||||
this.store.setSummary(nextText.slice(0, idx));
|
||||
}, 20);
|
||||
}
|
||||
|
||||
/** Publish a change to the camera grid chip; its label stays while hidden. */
|
||||
export function _publishCctvChip(patch) {
|
||||
const current = this._cctvChip ?? {
|
||||
visible: false,
|
||||
labelKey: msg('loading frames'),
|
||||
immediate: false,
|
||||
progress: '0/0',
|
||||
stopped: false,
|
||||
};
|
||||
// The chip keeps its English key, so a new locale shows it again; the
|
||||
// view gets the label alone.
|
||||
const next = { ...current, ...patch };
|
||||
next.label = t(next.labelKey);
|
||||
if (
|
||||
this._cctvChip &&
|
||||
Object.keys(next).every((key) => next[key] === current[key])
|
||||
)
|
||||
return;
|
||||
this._cctvChip = next;
|
||||
const { labelKey, ...chip } = next;
|
||||
this.statusStore.publishCctv(chip);
|
||||
}
|
||||
|
||||
export function _updateCctvSyncChip(loading, enabled) {
|
||||
if (this.destroyed) return;
|
||||
const total = Number(loading?.total) || 0;
|
||||
const loaded = Math.max(0, Math.min(Number(loading?.loaded) || 0, total));
|
||||
const busy = !!enabled && !!loading?.active && total > 0;
|
||||
|
||||
if (busy) {
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvChipHideTimer = null;
|
||||
this._cctvChipWasBusy = true;
|
||||
// The counter is left plain on purpose: it ticks every few frames
|
||||
// during a grid load, and flapping it would read as a slot machine.
|
||||
this._publishCctvChip({
|
||||
visible: true,
|
||||
labelKey: msg('loading frames'),
|
||||
progress: `${loaded}/${total}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._cctvChipWasBusy && enabled && total > 0) {
|
||||
// Load just completed — flash the final count, then auto-hide.
|
||||
this._cctvChipWasBusy = false;
|
||||
this._publishCctvChip({
|
||||
visible: true,
|
||||
labelKey: msg('camera grid ready'),
|
||||
progress: `${total}/${total}`,
|
||||
});
|
||||
clearTimeout(this._cctvChipHideTimer);
|
||||
this._cctvChipHideTimer = window.setTimeout(() => {
|
||||
if (this.destroyed) return;
|
||||
this._cctvChipHideTimer = null;
|
||||
this._publishCctvChip({ visible: false });
|
||||
}, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._cctvChipHideTimer) {
|
||||
this._cctvChipWasBusy = false;
|
||||
this._publishCctvChip({ visible: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Paint a second surface from the projection decoder, bounded to 640px/15 fps. */
|
||||
export function createCctvVideoSurface(
|
||||
canvas,
|
||||
getVideo,
|
||||
{
|
||||
requestFrame = requestAnimationFrame,
|
||||
cancelFrame = cancelAnimationFrame,
|
||||
} = {},
|
||||
) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
let handle = 0;
|
||||
let stopped = false;
|
||||
let previous = null;
|
||||
let previousTime = -1;
|
||||
let paintedAt = -Infinity;
|
||||
const paint = (now) => {
|
||||
if (stopped) return;
|
||||
const video = getVideo();
|
||||
if (video !== previous) {
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
previous = video;
|
||||
previousTime = -1;
|
||||
}
|
||||
if (
|
||||
ctx &&
|
||||
video?.readyState >= 2 &&
|
||||
video.videoWidth > 0 &&
|
||||
video.videoHeight > 0 &&
|
||||
now - paintedAt >= 1000 / 15 &&
|
||||
video.currentTime !== previousTime
|
||||
) {
|
||||
const width = Math.min(640, video.videoWidth);
|
||||
const height = Math.max(
|
||||
1,
|
||||
Math.round((width * video.videoHeight) / video.videoWidth),
|
||||
);
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
try {
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
previousTime = video.currentTime;
|
||||
paintedAt = now;
|
||||
} catch {
|
||||
/* A resolution/decode transition retries on the next frame. */
|
||||
}
|
||||
}
|
||||
handle = requestFrame(paint);
|
||||
};
|
||||
handle = requestFrame(paint);
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
cancelFrame(handle);
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createCctvVideoSurface } from './cctvVideo.js';
|
||||
test('second surface uses shared decoder, caps draws, clears switch and cancels teardown', () => {
|
||||
let callback;
|
||||
let draws = 0;
|
||||
let clears = 0;
|
||||
let cancelled = 0;
|
||||
const canvas = {
|
||||
width: 1,
|
||||
height: 1,
|
||||
getContext: () => ({ drawImage: () => draws++, clearRect: () => clears++ }),
|
||||
};
|
||||
let v = {
|
||||
readyState: 2,
|
||||
videoWidth: 1920,
|
||||
videoHeight: 1080,
|
||||
currentTime: 1,
|
||||
};
|
||||
const surface = createCctvVideoSurface(canvas, () => v, {
|
||||
requestFrame: (fn) => {
|
||||
callback = fn;
|
||||
return 1;
|
||||
},
|
||||
cancelFrame: () => cancelled++,
|
||||
});
|
||||
callback(0);
|
||||
callback(20);
|
||||
callback(80);
|
||||
assert.equal(draws, 1);
|
||||
assert.equal(canvas.width, 640);
|
||||
v = { ...v };
|
||||
callback(160);
|
||||
assert.equal(draws, 2);
|
||||
assert.equal(clears, 2);
|
||||
surface.stop();
|
||||
callback(200);
|
||||
assert.equal(cancelled, 1);
|
||||
assert.equal(draws, 2);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Keep the clear action focusable while busy; the caller owns its
|
||||
* transaction. The globe actions view renders the button from the globe
|
||||
* actions store now; this stays exported from `./ui/layers` for hosts that
|
||||
* imported it.
|
||||
* @deprecated
|
||||
*/
|
||||
export function bindClearLayersControl(button, clear) {
|
||||
let destroyed = false;
|
||||
const click = () => {
|
||||
if (!destroyed) void clear();
|
||||
};
|
||||
button?.addEventListener('click', click);
|
||||
return {
|
||||
setBusy(busy) {
|
||||
if (destroyed || !button) return;
|
||||
button.setAttribute('aria-disabled', String(busy));
|
||||
button.setAttribute('aria-busy', String(busy));
|
||||
button.setAttribute(
|
||||
'aria-label',
|
||||
busy
|
||||
? t('Clearing selected data layers')
|
||||
: t('Clear selected data layers'),
|
||||
);
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
button?.removeEventListener('click', click);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CockpitViewController } from './cockpitController.js';
|
||||
export { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The Cockpit briefing's pages and the state it starts in. It needs only the
|
||||
* i18n module, so the controller and the view can both read it.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const COCKPIT_BRIEF_PAGES = [
|
||||
{
|
||||
id: 'signals',
|
||||
kickerKey: msg('LIVE SIGNALS'),
|
||||
subtitleKey: msg('OBSERVED / MAPPED PINGS'),
|
||||
sourceKey: msg('SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS'),
|
||||
},
|
||||
{
|
||||
id: 'news',
|
||||
kickerKey: msg('REGIONAL NEWS'),
|
||||
subtitleKey: msg('LATEST LOCATION-MATCHED REPORTING'),
|
||||
sourceKey: msg('{source} · LOCATION QUERY · RECENT'),
|
||||
sourceName: 'GOOGLE NEWS RSS', // i18n-ignore: provider name
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
kickerKey: msg('LOCAL INFO'),
|
||||
subtitleKey: msg('PLACE / CONDITIONS / POSITION'),
|
||||
// Attribution stays English.
|
||||
source: 'NATURAL EARTH · OPEN-METEO · UTC',
|
||||
},
|
||||
];
|
||||
|
||||
// The cycle toggle's tooltip, as literal keys for the extractor.
|
||||
export const CYCLE_HELP_KEYS = Object.freeze({
|
||||
on: msg(
|
||||
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
|
||||
),
|
||||
off: msg(
|
||||
'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.',
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* The briefing as it first renders: the Live Signals page, cycling off, news
|
||||
* still loading and no region yet. The controller keeps it, changes it in
|
||||
* place and publishes a copy; `CockpitBriefing` renders it.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createBriefState() {
|
||||
const page = COCKPIT_BRIEF_PAGES[0];
|
||||
return {
|
||||
pageIndex: 0,
|
||||
pageId: page.id,
|
||||
kicker: t(page.kickerKey),
|
||||
subtitle: t(page.subtitleKey),
|
||||
position: `1 / ${COCKPIT_BRIEF_PAGES.length}`,
|
||||
source: t(page.sourceKey),
|
||||
autoRotate: false,
|
||||
autoLabel: t('CYCLE OFF'),
|
||||
autoHelp: t(CYCLE_HELP_KEYS.off),
|
||||
regionalStatus: null,
|
||||
news: {
|
||||
statusHidden: false,
|
||||
state: null,
|
||||
status: t('ACQUIRING REGIONAL NEWS'),
|
||||
articles: [],
|
||||
},
|
||||
local: {
|
||||
place: t('RESOLVING REGION'),
|
||||
coordinates: '--',
|
||||
temperature: '—',
|
||||
wind: '—',
|
||||
windDirection: '—',
|
||||
condition: t('UNKNOWN'),
|
||||
cloud: '—',
|
||||
precipitation: '—',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/** Regional briefing requests, pages and rotation for the Cockpit controller. */
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_BRIEF_ROTATE_MS,
|
||||
COCKPIT_REGIONAL_REFRESH_MS,
|
||||
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
|
||||
formatCockpitBriefAge,
|
||||
formatCockpitCoordinate,
|
||||
formatCockpitWindDirection,
|
||||
} from './cockpitPresentation.js';
|
||||
import {
|
||||
COCKPIT_BRIEF_PAGES,
|
||||
CYCLE_HELP_KEYS,
|
||||
createBriefState,
|
||||
} from './cockpitBriefPages.js';
|
||||
|
||||
// regionalModel.weatherCodeLabel is shared with node code and returns
|
||||
// English; translate it here.
|
||||
const WEATHER_CONDITION_KEYS = Object.freeze({
|
||||
'CONDITIONS UNKNOWN': msg('CONDITIONS UNKNOWN'),
|
||||
CLEAR: msg('CLEAR'),
|
||||
'PARTLY CLOUDY': msg('PARTLY CLOUDY'),
|
||||
OVERCAST: msg('OVERCAST'),
|
||||
FOG: msg('FOG'),
|
||||
DRIZZLE: msg('DRIZZLE'),
|
||||
RAIN: msg('RAIN'),
|
||||
SNOW: msg('SNOW'),
|
||||
'RAIN SHOWERS': msg('RAIN SHOWERS'),
|
||||
'SNOW SHOWERS': msg('SNOW SHOWERS'),
|
||||
THUNDERSTORM: msg('THUNDERSTORM'),
|
||||
'MIXED CONDITIONS': msg('MIXED CONDITIONS'),
|
||||
});
|
||||
|
||||
/** Hand a fresh copy of the briefing to the view. */
|
||||
export function publishBriefView() {
|
||||
const brief = this.brief;
|
||||
this.publishBrief({
|
||||
...brief,
|
||||
news: { ...brief.news },
|
||||
local: { ...brief.local },
|
||||
});
|
||||
}
|
||||
|
||||
export function showBriefPage(index, { manual = false } = {}) {
|
||||
if (this.destroyed) return;
|
||||
const count = COCKPIT_BRIEF_PAGES.length;
|
||||
this.briefPageIndex = ((Number(index) % count) + count) % count;
|
||||
const page = COCKPIT_BRIEF_PAGES[this.briefPageIndex];
|
||||
Object.assign(this.brief, {
|
||||
pageIndex: this.briefPageIndex,
|
||||
pageId: page.id,
|
||||
kicker: t(page.kickerKey),
|
||||
subtitle: t(page.subtitleKey),
|
||||
position: `${this.briefPageIndex + 1} / ${count}`,
|
||||
source: page.sourceKey
|
||||
? t(page.sourceKey, { source: page.sourceName })
|
||||
: page.source,
|
||||
});
|
||||
this.publishBriefView();
|
||||
if (manual && this.briefAutoRotateEnabled)
|
||||
this.startBriefRotation({ reset: true });
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
|
||||
// The state is the accessible name; the help is the tooltip.
|
||||
function cycleWords(enabled) {
|
||||
return {
|
||||
autoRotate: enabled,
|
||||
autoLabel: enabled ? t('CYCLE ON') : t('CYCLE OFF'),
|
||||
autoHelp: t(CYCLE_HELP_KEYS[enabled ? 'on' : 'off']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Word the briefing again in the current locale: its page, cycle toggle and
|
||||
* whatever the regional card shows.
|
||||
*/
|
||||
export function relocalizeBrief() {
|
||||
if (this.destroyed) return;
|
||||
if (this.brief.news.state === null) {
|
||||
const { news, local } = createBriefState();
|
||||
this.brief.news = news;
|
||||
this.brief.local = { ...local, coordinates: this.brief.local.coordinates };
|
||||
}
|
||||
Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled));
|
||||
this.showBriefPage(this.briefPageIndex);
|
||||
if (this.regionalBrief)
|
||||
this.renderRegionalBrief(this.regionalBrief, this.lastAircraftInfo);
|
||||
else if (this.brief.news.state)
|
||||
this.renderRegionalBriefStatus(
|
||||
this.brief.news.state,
|
||||
this.lastAircraftInfo,
|
||||
);
|
||||
}
|
||||
|
||||
export function setBriefAutoRotate(enabled) {
|
||||
if (this.destroyed) return;
|
||||
this.briefAutoRotateEnabled = Boolean(enabled);
|
||||
Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled));
|
||||
this.publishBriefView();
|
||||
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
|
||||
else this.stopBriefRotation();
|
||||
}
|
||||
|
||||
export function startBriefRotation({ reset = false } = {}) {
|
||||
if (this.destroyed) return;
|
||||
if (reset) this.stopBriefRotation();
|
||||
if (
|
||||
!this.briefAutoRotateEnabled ||
|
||||
this.briefTimer ||
|
||||
!this.active ||
|
||||
this.signalCollapsed ||
|
||||
document.hidden
|
||||
)
|
||||
return;
|
||||
this.briefTimer = window.setTimeout(() => {
|
||||
this.briefTimer = null;
|
||||
if (this.destroyed) return;
|
||||
const hasPointer = this.signalStream?.matches(':hover') === true;
|
||||
const hasFocus =
|
||||
this.signalStream?.contains(document.activeElement) === true;
|
||||
const isInteracting = hasPointer || hasFocus;
|
||||
if (!isInteracting) {
|
||||
this.showBriefPage(this.briefPageIndex + 1);
|
||||
}
|
||||
this.startBriefRotation();
|
||||
}, COCKPIT_BRIEF_ROTATE_MS);
|
||||
}
|
||||
|
||||
export function stopBriefRotation() {
|
||||
if (this.briefTimer) window.clearTimeout(this.briefTimer);
|
||||
this.briefTimer = null;
|
||||
}
|
||||
|
||||
/** @returns {string} The position line for the Local page. */
|
||||
function localCoordinates(info) {
|
||||
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude))
|
||||
return t('POSITION UNAVAILABLE');
|
||||
const lat = formatCockpitCoordinate(info.latitude, 'N', 'S');
|
||||
const lon = formatCockpitCoordinate(info.longitude, 'E', 'W');
|
||||
return `${lat} · ${lon}`;
|
||||
}
|
||||
|
||||
export function updateLocalPosition(info) {
|
||||
const coordinates = localCoordinates(info);
|
||||
if (coordinates === this.brief.local.coordinates) return;
|
||||
this.brief.local.coordinates = coordinates;
|
||||
this.publishBriefView();
|
||||
}
|
||||
|
||||
export function maybeRefreshRegionalBrief(info) {
|
||||
if (this.destroyed) return;
|
||||
if (
|
||||
!this.active ||
|
||||
!Number.isFinite(info.latitude) ||
|
||||
!Number.isFinite(info.longitude)
|
||||
)
|
||||
return;
|
||||
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`;
|
||||
if (subjectId !== this.regionalBriefSubjectId) {
|
||||
this.regionalBriefAbort?.abort();
|
||||
this.regionalBriefAbort = null;
|
||||
this.regionalBriefRequestToken += 1;
|
||||
this.regionalBriefSubjectId = subjectId;
|
||||
this.regionalBrief = null;
|
||||
this.regionalBriefAnchor = null;
|
||||
this.regionalBriefFetchedAt = 0;
|
||||
}
|
||||
const point = { latitude: info.latitude, longitude: info.longitude };
|
||||
const ageMs = Date.now() - this.regionalBriefFetchedAt;
|
||||
const distanceM = this.services.regionalDistanceM(
|
||||
this.regionalBriefAnchor,
|
||||
point,
|
||||
);
|
||||
if (
|
||||
this.regionalBriefAbort ||
|
||||
(ageMs < COCKPIT_REGIONAL_REFRESH_MS &&
|
||||
distanceM < COCKPIT_REGIONAL_REFRESH_DISTANCE_M)
|
||||
)
|
||||
return;
|
||||
|
||||
this.regionalBriefAnchor = point;
|
||||
this.regionalBriefFetchedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const requestToken = ++this.regionalBriefRequestToken;
|
||||
this.regionalBriefAbort = controller;
|
||||
if (!this.regionalBrief) this.renderRegionalBriefStatus('loading', info);
|
||||
this.services
|
||||
.fetchRegionalBrief(point.latitude, point.longitude, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((payload) => {
|
||||
if (
|
||||
!this.active ||
|
||||
requestToken !== this.regionalBriefRequestToken ||
|
||||
subjectId !== this.regionalBriefSubjectId
|
||||
)
|
||||
return;
|
||||
this.regionalBrief = payload;
|
||||
this.renderRegionalBrief(payload, info);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (
|
||||
error?.name !== 'AbortError' &&
|
||||
this.active &&
|
||||
requestToken === this.regionalBriefRequestToken &&
|
||||
subjectId === this.regionalBriefSubjectId
|
||||
) {
|
||||
this.renderRegionalBriefStatus('unavailable', info);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.regionalBriefAbort === controller)
|
||||
this.regionalBriefAbort = null;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderRegionalBriefStatus(status, info) {
|
||||
const { news, local } = this.brief;
|
||||
news.statusHidden = false;
|
||||
news.state = status;
|
||||
news.status =
|
||||
status === 'loading'
|
||||
? t('ACQUIRING REGIONAL NEWS')
|
||||
: t('REGIONAL NEWS UNAVAILABLE');
|
||||
if (status === 'unavailable') news.articles = [];
|
||||
if (status === 'loading') local.place = t('RESOLVING REGION');
|
||||
if (status === 'unavailable') local.place = t('REGION UNAVAILABLE');
|
||||
local.coordinates = localCoordinates(info);
|
||||
this.publishBriefView();
|
||||
}
|
||||
|
||||
export function renderRegionalBrief(payload, info) {
|
||||
const articles = Array.isArray(payload?.articles) ? payload.articles : [];
|
||||
const seen = new Map();
|
||||
const storyKey = (url) => {
|
||||
const count = seen.get(url) ?? 0;
|
||||
seen.set(url, count + 1);
|
||||
return count ? `${url}#${count}` : String(url);
|
||||
};
|
||||
const weather = payload?.weather;
|
||||
const condition = this.services.weatherCodeLabel(weather?.weatherCode);
|
||||
const conditionKey = WEATHER_CONDITION_KEYS[condition];
|
||||
const placeLabel =
|
||||
payload?.place?.label || payload?.place?.country || t('REGION UNAVAILABLE');
|
||||
this.brief.news = {
|
||||
statusHidden: articles.length > 0,
|
||||
state: payload?.newsStatus || 'unavailable',
|
||||
status:
|
||||
payload?.newsStatus === 'empty'
|
||||
? t('NO RECENT LOCATION MATCHES')
|
||||
: t('REGIONAL NEWS UNAVAILABLE'),
|
||||
articles: articles.slice(0, 4).map((article) => ({
|
||||
// A row keeps its node, and so its focus, only for the same story.
|
||||
key: storyKey(article.url),
|
||||
url: article.url,
|
||||
title: article.title,
|
||||
meta: t('{domain} · {age}', {
|
||||
domain: article.domain || t('SOURCE'),
|
||||
age: formatCockpitBriefAge(article.publishedAt),
|
||||
}),
|
||||
})),
|
||||
};
|
||||
this.brief.local = {
|
||||
place: placeLabel.toUpperCase(),
|
||||
coordinates: localCoordinates(info),
|
||||
temperature: Number.isFinite(weather?.temperatureC)
|
||||
? `${Math.round(weather.temperatureC)}°C`
|
||||
: '—',
|
||||
wind: Number.isFinite(weather?.windKph)
|
||||
? t('{value} KM/H', { value: Math.round(weather.windKph) })
|
||||
: '—',
|
||||
windDirection: formatCockpitWindDirection(weather?.windDirectionDeg),
|
||||
condition: conditionKey ? t(conditionKey) : condition,
|
||||
cloud: Number.isFinite(weather?.cloudCoverPct)
|
||||
? t('CLOUD {pct}%', { pct: Math.round(weather.cloudCoverPct) })
|
||||
: t('CLOUD UNKNOWN'),
|
||||
precipitation: Number.isFinite(weather?.precipitationMm)
|
||||
? formatNumber(weather.precipitationMm, {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
})
|
||||
: '—',
|
||||
};
|
||||
this.brief.regionalStatus = payload?.status || 'partial';
|
||||
if (this.briefPageIndex === 1) {
|
||||
this.brief.source = t('{source} · LOCATION QUERY', {
|
||||
source: String(payload?.newsSource || t('REGIONAL NEWS')).toUpperCase(),
|
||||
});
|
||||
}
|
||||
this.publishBriefView();
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// src/ui/cockpitBriefing.test.mjs — the Cockpit briefing card renders what
|
||||
// the Cockpit controller presents, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createBriefState } from './cockpitBriefPages.js';
|
||||
import {
|
||||
publishBriefView,
|
||||
renderRegionalBrief,
|
||||
renderRegionalBriefStatus,
|
||||
setBriefAutoRotate,
|
||||
showBriefPage,
|
||||
updateLocalPosition,
|
||||
} from './cockpitBriefing.js';
|
||||
import { setSignalCollapsed } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitBriefing from './views/panels/CockpitBriefing.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitBriefing).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useCockpitStore(pinia);
|
||||
// The briefing presenters need only this much of the controller.
|
||||
const owner = {
|
||||
destroyed: false,
|
||||
briefPageIndex: 0,
|
||||
briefAutoRotateEnabled: false,
|
||||
brief: createBriefState(),
|
||||
publishBrief: (view) => store.publishBrief(view),
|
||||
publishBriefView,
|
||||
scheduleContextLayout() {},
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
services: { weatherCodeLabel: () => 'RAIN' },
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const text = (selector) => $(selector).textContent.trim();
|
||||
const visiblePages = () =>
|
||||
[...document.querySelectorAll('[data-cockpit-brief-page]')]
|
||||
.filter((page) => !page.hidden)
|
||||
.map((page) => page.dataset.cockpitBriefPage);
|
||||
const current = () =>
|
||||
[...document.querySelectorAll('[data-cockpit-brief-index]')].map((tab) =>
|
||||
tab.getAttribute('aria-current'),
|
||||
);
|
||||
return { $, text, owner, visiblePages, current };
|
||||
}
|
||||
|
||||
const INFO = { latitude: 30.25, longitude: -97.75 };
|
||||
|
||||
test('before the controller presents, the card shows the state it starts in', (t) => {
|
||||
const { $, text, visiblePages, current } = fixture(t);
|
||||
assert.equal(text('#cockpit-brief-kicker'), 'LIVE SIGNALS');
|
||||
assert.equal(text('#cockpit-brief-subtitle'), 'OBSERVED / MAPPED PINGS');
|
||||
assert.equal(text('#cockpit-brief-position'), '1 / 3');
|
||||
assert.equal(
|
||||
text('#cockpit-brief-source'),
|
||||
'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
|
||||
);
|
||||
assert.deepEqual(visiblePages(), ['signals']);
|
||||
assert.deepEqual(current(), ['true', null, null]);
|
||||
const auto = $('#cockpit-brief-auto');
|
||||
assert.equal(auto.textContent, 'CYCLE OFF');
|
||||
assert.equal(auto.getAttribute('aria-label'), 'CYCLE OFF');
|
||||
assert.equal(auto.getAttribute('aria-pressed'), 'false');
|
||||
assert.match(auto.title, /^Cycle briefing pages automatically/);
|
||||
assert.equal(text('#cockpit-news-status'), 'ACQUIRING REGIONAL NEWS');
|
||||
assert.equal($('#cockpit-news-status').hasAttribute('data-state'), false);
|
||||
assert.equal(text('#cockpit-local-place'), 'RESOLVING REGION');
|
||||
assert.equal(text('#cockpit-local-coordinates'), '--');
|
||||
assert.equal(text('#cockpit-local-condition'), 'UNKNOWN');
|
||||
const stream = $('#cockpit-signal-stream');
|
||||
assert.equal(stream.hidden, true);
|
||||
assert.equal(stream.hasAttribute('data-brief-page'), false);
|
||||
assert.equal(stream.hasAttribute('data-regional-status'), false);
|
||||
});
|
||||
|
||||
test('pages, the cycle toggle and the source follow the controller', async (t) => {
|
||||
const { $, text, owner, visiblePages, current } = fixture(t);
|
||||
showBriefPage.call(owner, 1);
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-brief-kicker'), 'REGIONAL NEWS');
|
||||
assert.equal(text('#cockpit-brief-position'), '2 / 3');
|
||||
assert.equal(
|
||||
text('#cockpit-brief-source'),
|
||||
'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
|
||||
);
|
||||
assert.deepEqual(visiblePages(), ['news']);
|
||||
assert.deepEqual(current(), ['false', 'true', 'false']);
|
||||
assert.equal($('#cockpit-signal-stream').dataset.briefPage, 'news');
|
||||
showBriefPage.call(owner, -1);
|
||||
await nextTick();
|
||||
assert.deepEqual(visiblePages(), ['local']);
|
||||
assert.equal(
|
||||
text('#cockpit-brief-source'),
|
||||
'NATURAL EARTH · OPEN-METEO · UTC',
|
||||
);
|
||||
setBriefAutoRotate.call(owner, true);
|
||||
await nextTick();
|
||||
const auto = $('#cockpit-brief-auto');
|
||||
assert.equal(auto.textContent, 'CYCLE ON');
|
||||
assert.equal(auto.getAttribute('aria-label'), 'CYCLE ON');
|
||||
assert.equal(auto.getAttribute('aria-pressed'), 'true');
|
||||
assert.match(auto.title, /^Stop automatic page cycling/);
|
||||
});
|
||||
|
||||
test('regional news and local conditions render what the region returned', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
renderRegionalBriefStatus.call(owner, 'loading', INFO);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-news-status').dataset.state, 'loading');
|
||||
assert.match(text('#cockpit-local-coordinates'), /N · .*W$/);
|
||||
showBriefPage.call(owner, 1);
|
||||
renderRegionalBrief.call(
|
||||
owner,
|
||||
{
|
||||
status: 'ok',
|
||||
newsStatus: 'ok',
|
||||
newsSource: 'wire',
|
||||
place: { label: 'Austin, Texas' },
|
||||
articles: Array.from({ length: 5 }, (_, index) => ({
|
||||
url: `https://example.com/${index}`,
|
||||
title: `Story ${index}`,
|
||||
domain: 'example.com',
|
||||
publishedAt: new Date().toISOString(),
|
||||
})),
|
||||
weather: {
|
||||
temperatureC: 21.6,
|
||||
windKph: 12.2,
|
||||
windDirectionDeg: 90,
|
||||
weatherCode: 61,
|
||||
cloudCoverPct: 40,
|
||||
precipitationMm: 1.25,
|
||||
},
|
||||
},
|
||||
INFO,
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-news-status').hidden, true);
|
||||
const links = [...document.querySelectorAll('#cockpit-news-list li a')];
|
||||
assert.deepEqual(
|
||||
links.map((link) => link.querySelector('strong').textContent),
|
||||
['Story 0', 'Story 1', 'Story 2', 'Story 3'],
|
||||
);
|
||||
assert.equal(links[0].getAttribute('href'), 'https://example.com/0');
|
||||
assert.equal(links[0].target, '_blank');
|
||||
assert.equal(links[0].rel, 'noopener noreferrer');
|
||||
assert.match(links[0].querySelector('span').textContent, /^example\.com · /);
|
||||
assert.equal(text('#cockpit-local-place'), 'AUSTIN, TEXAS');
|
||||
assert.equal(text('#cockpit-local-temperature'), '22°C');
|
||||
assert.equal(text('#cockpit-local-wind'), '12 KM/H');
|
||||
assert.equal(text('#cockpit-local-condition'), 'RAIN');
|
||||
assert.equal(text('#cockpit-local-cloud'), 'CLOUD 40%');
|
||||
assert.equal(text('#cockpit-local-precipitation'), '1.3');
|
||||
assert.equal(text('#cockpit-brief-source'), 'WIRE · LOCATION QUERY');
|
||||
assert.equal($('#cockpit-signal-stream').dataset.regionalStatus, 'ok');
|
||||
renderRegionalBriefStatus.call(owner, 'unavailable', {});
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-news-status').hidden, false);
|
||||
assert.equal(text('#cockpit-news-status'), 'REGIONAL NEWS UNAVAILABLE');
|
||||
assert.equal(document.querySelectorAll('#cockpit-news-list li').length, 0);
|
||||
assert.equal(text('#cockpit-local-place'), 'REGION UNAVAILABLE');
|
||||
assert.equal(text('#cockpit-local-coordinates'), 'POSITION UNAVAILABLE');
|
||||
});
|
||||
|
||||
const story = (id, url = `https://example.com/${id}`) => ({
|
||||
url,
|
||||
title: `Story ${id}`,
|
||||
domain: 'example.com',
|
||||
publishedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
test('a refreshed news list keeps a focused story on its own link', async (t) => {
|
||||
const { $, owner } = fixture(t);
|
||||
const render = (articles) =>
|
||||
renderRegionalBrief.call(owner, { newsStatus: 'ok', articles }, INFO);
|
||||
render([story('a'), story('b'), story('c')]);
|
||||
await nextTick();
|
||||
const link = (title) =>
|
||||
[...document.querySelectorAll('#cockpit-news-list a')].find(
|
||||
(anchor) => anchor.querySelector('strong').textContent === title,
|
||||
);
|
||||
const focused = link('Story b');
|
||||
focused.focus();
|
||||
render([story('b'), story('d'), story('a')]);
|
||||
await nextTick();
|
||||
// Focus may drop when rows move, as it did when the list was rebuilt,
|
||||
// but a link never turns into another story under it.
|
||||
assert.equal(link('Story b') === focused, true);
|
||||
assert.equal(focused.getAttribute('href'), 'https://example.com/b');
|
||||
const active = document.activeElement;
|
||||
assert.equal(
|
||||
active === focused || !$('#cockpit-news-list').contains(active),
|
||||
true,
|
||||
);
|
||||
// Two stories at one address still render as two rows.
|
||||
render([
|
||||
story('x', 'https://example.com/same'),
|
||||
story('y', 'https://example.com/same'),
|
||||
]);
|
||||
await nextTick();
|
||||
assert.equal(document.querySelectorAll('#cockpit-news-list li').length, 2);
|
||||
assert.equal($('#cockpit-news-list').textContent.includes('Story y'), true);
|
||||
});
|
||||
|
||||
test('the news source names the feed only while it is current on page 2', async (t) => {
|
||||
const { text, owner } = fixture(t);
|
||||
showBriefPage.call(owner, 1);
|
||||
renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO);
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-brief-source'), 'WIRE · LOCATION QUERY');
|
||||
showBriefPage.call(owner, 2);
|
||||
showBriefPage.call(owner, 1);
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
text('#cockpit-brief-source'),
|
||||
'GOOGLE NEWS RSS · LOCATION QUERY · RECENT',
|
||||
);
|
||||
showBriefPage.call(owner, 0);
|
||||
renderRegionalBrief.call(owner, { newsSource: 'wire', articles: [] }, INFO);
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
text('#cockpit-brief-source'),
|
||||
'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unchanged position publishes nothing new', (t) => {
|
||||
const { owner } = fixture(t);
|
||||
const views = [];
|
||||
owner.publishBrief = (view) => views.push(view);
|
||||
updateLocalPosition.call(owner, INFO);
|
||||
updateLocalPosition.call(owner, INFO);
|
||||
assert.equal(views.length, 1);
|
||||
});
|
||||
|
||||
test('the route card sits in the briefing card, hidden until a route', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
const route = $('#cockpit-signal-stream > #cockpit-route');
|
||||
assert.equal(route?.hidden, true);
|
||||
});
|
||||
|
||||
test('the card is labelled in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, text, owner } = fixture(t);
|
||||
const auto = $('#cockpit-brief-auto');
|
||||
assert.equal(text('#cockpit-brief-kicker'), 'СИГНАЛЫ ОНЛАЙН');
|
||||
assert.equal(auto.textContent, 'АВТО: ВЫКЛ');
|
||||
assert.equal(auto.getAttribute('aria-label'), 'АВТО: ВЫКЛ');
|
||||
assert.match(auto.title, /^Автоматически листать/);
|
||||
showBriefPage.call(owner, 1);
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-brief-kicker'), 'НОВОСТИ РЕГИОНА');
|
||||
});
|
||||
|
||||
test('the card and its toggle render the collapse the controller presents', async (t) => {
|
||||
const { $, owner } = fixture(t);
|
||||
const store = useCockpitStore();
|
||||
Object.assign(owner, {
|
||||
active: true,
|
||||
signalCollapsed: false,
|
||||
presence: { active: true, signalCollapsed: false },
|
||||
publishPresenceView: () => store.publishPresence({ ...owner.presence }),
|
||||
});
|
||||
const card = $('#cockpit-signal-stream');
|
||||
const toggle = $('#cockpit-signal-toggle');
|
||||
assert.equal(card.hasAttribute('data-collapsed'), false);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
|
||||
setSignalCollapsed.call(owner, true, { user: true });
|
||||
await nextTick();
|
||||
assert.equal(card.dataset.collapsed, 'true');
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(toggle.getAttribute('aria-label'), 'Expand cockpit briefing panel');
|
||||
assert.equal(toggle.title, 'Expand briefing panel');
|
||||
assert.equal(toggle.textContent.trim(), 'right_panel_open');
|
||||
assert.equal(owner.signalUserCollapsed, true);
|
||||
setSignalCollapsed.call(owner, false);
|
||||
await nextTick();
|
||||
assert.equal(card.dataset.collapsed, 'false');
|
||||
assert.equal(toggle.textContent.trim(), 'right_panel_close');
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
cockpitAnchorCorrectionStep,
|
||||
cockpitGroundSafeHeight,
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
slewHeading,
|
||||
} from '../cockpitMath.js';
|
||||
import {
|
||||
COCKPIT_HEADING_SLEW_DPS,
|
||||
COCKPIT_FORWARD_OFFSET_M,
|
||||
COCKPIT_UP_OFFSET_M,
|
||||
COCKPIT_MIN_GROUND_CLEARANCE_M,
|
||||
COCKPIT_VIEW_PITCH_DEG,
|
||||
COCKPIT_CAMERA_UPDATE_MS,
|
||||
COCKPIT_HUD_UPDATE_MS,
|
||||
COCKPIT_GROUND_PROBE_MS,
|
||||
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
export function update() {
|
||||
if (this.destroyed) return false;
|
||||
if (!this.active) {
|
||||
// Entry availability changes on a human timescale (tracking start/stop,
|
||||
// info arriving after a poll) — polling it every rendered frame ran
|
||||
// readAircraftInfo() + DOM pokes at display rate in plain map mode.
|
||||
// 250 ms keeps the chip imperceptibly fresh; trackedEntityChanged still
|
||||
// fires syncEntry immediately on the events that matter. (perf item 9)
|
||||
const nowMs = performance.now();
|
||||
if (nowMs - (this._lastEntrySyncMs || 0) >= 250) {
|
||||
this._lastEntrySyncMs = nowMs;
|
||||
this.syncEntry();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = performance.now();
|
||||
const info = this.readAircraftInfo();
|
||||
|
||||
// Adopt a newly selected track before the camera cadence gate. A context
|
||||
// NEXT/PREV selection can otherwise spend one frame driving the old
|
||||
// aircraft with the new aircraft's metadata.
|
||||
this._adoptTrackedEntity(nowMs, info);
|
||||
if (
|
||||
!info ||
|
||||
!this.trackedEntity ||
|
||||
!this.viewer.entities.contains(this.trackedEntity)
|
||||
) {
|
||||
if (nowMs < this.contextNavigationDeadlineMs) return;
|
||||
this.exit({ restoreTracking: false });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!cockpitUiUpdateDue(
|
||||
nowMs,
|
||||
this.lastCameraUpdateMs,
|
||||
COCKPIT_CAMERA_UPDATE_MS,
|
||||
)
|
||||
)
|
||||
return;
|
||||
this.lastCameraUpdateMs = nowMs;
|
||||
|
||||
const target = this.trackedEntity.position.getValue(
|
||||
this.viewer.clock.currentTime,
|
||||
this.scratchTarget,
|
||||
);
|
||||
if (!target) return;
|
||||
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
|
||||
this.lastFrameMs = nowMs;
|
||||
if (Number.isFinite(info.track)) {
|
||||
this.heading = slewHeading(
|
||||
this.heading ?? info.track,
|
||||
info.track,
|
||||
COCKPIT_HEADING_SLEW_DPS * dtSec,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.cockpitAnchorValid) {
|
||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||
this.cockpitAnchorValid = true;
|
||||
}
|
||||
|
||||
// First-person motion cannot use the delayed feed correction as a raw
|
||||
// camera destination: a harmless icon re-anchor becomes a whole-world
|
||||
// surge/reversal in cockpit view. Advance the camera anchor inertially
|
||||
// from the reported course/speed and converge on the authoritative layer
|
||||
// display position at a bounded rate. This preserves the layer's required
|
||||
// 15/30-second interpolation and per-frame cache without exposing its
|
||||
// sample-boundary corrections to the camera.
|
||||
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
|
||||
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
|
||||
const speedMps = Number.isFinite(info.velocityMps)
|
||||
? Math.max(0, info.velocityMps)
|
||||
: 0;
|
||||
|
||||
if (info.stale) {
|
||||
// A feed backoff has no authoritative velocity epoch to advance from.
|
||||
// Hold the cockpit on the exact layer-rendered position so the camera
|
||||
// cannot coast away while the icon correctly remains fixed.
|
||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||
} else {
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
this.cockpitAnchor,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchEnu,
|
||||
);
|
||||
this.scratchLocal.x = Math.sin(headingRad);
|
||||
this.scratchLocal.y = Math.cos(headingRad);
|
||||
this.scratchLocal.z = 0;
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
this.scratchEnu,
|
||||
this.scratchLocal,
|
||||
this.scratchHorizontal,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
this.scratchHorizontal,
|
||||
speedMps * dtSec,
|
||||
this.scratchAdvance,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
this.cockpitAnchor,
|
||||
this.scratchAdvance,
|
||||
this.cockpitAnchor,
|
||||
);
|
||||
Cesium.Cartesian3.subtract(
|
||||
target,
|
||||
this.cockpitAnchor,
|
||||
this.scratchCorrection,
|
||||
);
|
||||
const correctionDistanceM = Cesium.Cartesian3.magnitude(
|
||||
this.scratchCorrection,
|
||||
);
|
||||
const correctionStepM = cockpitAnchorCorrectionStep(
|
||||
correctionDistanceM,
|
||||
speedMps,
|
||||
dtSec,
|
||||
);
|
||||
if (correctionStepM > 0 && correctionDistanceM > 0) {
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
this.scratchCorrection,
|
||||
correctionStepM / correctionDistanceM,
|
||||
this.scratchCorrection,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
this.cockpitAnchor,
|
||||
this.scratchCorrection,
|
||||
this.cockpitAnchor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The inertial anchor is independent of the layer's render-floor clamp and
|
||||
// can otherwise coast into a photoreal mesh while a landing contact is
|
||||
// between fixes. Clamp it against the same mesh-first shared floor used by
|
||||
// aircraft rendering. For a slow contact whose floor cell is still cold,
|
||||
// its already-clamped render position is a conservative temporary floor.
|
||||
const anchorCartographic = Cesium.Cartographic.fromCartesian(
|
||||
this.cockpitAnchor,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchAnchorCartographic,
|
||||
);
|
||||
const targetCartographic = Cesium.Cartographic.fromCartesian(
|
||||
target,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchTargetCartographic,
|
||||
);
|
||||
let cockpitFloorM = this.services.cachedGroundFloor(
|
||||
info.latitude,
|
||||
info.longitude,
|
||||
);
|
||||
if (info.onGround === true) {
|
||||
const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
|
||||
this.services.warmGroundFloor(groundPoint);
|
||||
const meshFloorM = this.services.cachedMeshFloor(
|
||||
info.latitude,
|
||||
info.longitude,
|
||||
);
|
||||
if (this.services.meshFloorPreferred() && !Number.isFinite(meshFloorM)) {
|
||||
if (
|
||||
cockpitUiUpdateDue(
|
||||
nowMs,
|
||||
this.lastGroundProbeMs,
|
||||
COCKPIT_GROUND_PROBE_MS,
|
||||
)
|
||||
) {
|
||||
this.lastGroundProbeMs = nowMs;
|
||||
const viewerCartographic = this.viewer.camera.positionCartographic;
|
||||
this.services.sampleMeshFloorCells(this.viewer.scene, groundPoint, {
|
||||
excludeObjects: [this.trackedEntity],
|
||||
viewerLat: Cesium.Math.toDegrees(viewerCartographic.latitude),
|
||||
viewerLon: Cesium.Math.toDegrees(viewerCartographic.longitude),
|
||||
});
|
||||
}
|
||||
cockpitFloorM = this.services.cachedMeshFloor(
|
||||
info.latitude,
|
||||
info.longitude,
|
||||
);
|
||||
if (!Number.isFinite(cockpitFloorM)) {
|
||||
if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs;
|
||||
if (
|
||||
!cockpitSurfaceWaitExpired(
|
||||
nowMs,
|
||||
this.surfaceWaitStartedMs,
|
||||
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
||||
)
|
||||
) {
|
||||
// Keep the already-safe map camera in place while the photoreal
|
||||
// surface under a parked aircraft is acquired. The bounded wait
|
||||
// prevents a permanently cold mesh cell from freezing cockpit.
|
||||
this.surfaceAcquiring = true;
|
||||
this.surfaceFallback = false;
|
||||
if (
|
||||
cockpitUiUpdateDue(
|
||||
nowMs,
|
||||
this.lastHudUpdateMs,
|
||||
COCKPIT_HUD_UPDATE_MS,
|
||||
)
|
||||
) {
|
||||
this.lastHudUpdateMs = nowMs;
|
||||
this.updateHud(info, nowMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = true;
|
||||
} else {
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
}
|
||||
if (
|
||||
!Number.isFinite(cockpitFloorM) &&
|
||||
speedMps < 90 &&
|
||||
Number.isFinite(targetCartographic?.height)
|
||||
) {
|
||||
cockpitFloorM =
|
||||
targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
|
||||
}
|
||||
if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
|
||||
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
||||
anchorCartographic.height,
|
||||
cockpitFloorM,
|
||||
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
|
||||
);
|
||||
if (minimumAnchorHeightM !== anchorCartographic.height) {
|
||||
anchorCartographic.height = minimumAnchorHeightM;
|
||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||
anchorCartographic,
|
||||
this.cockpitAnchor,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the local frame at the stabilized anchor after advancing it.
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(
|
||||
this.cockpitAnchor,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchEnu,
|
||||
);
|
||||
this.scratchLocal.x = Math.sin(headingRad);
|
||||
this.scratchLocal.y = Math.cos(headingRad);
|
||||
this.scratchLocal.z = 0;
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
this.scratchEnu,
|
||||
this.scratchLocal,
|
||||
this.scratchHorizontal,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal);
|
||||
|
||||
this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad);
|
||||
this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad);
|
||||
this.scratchLocal.z = Math.sin(pitchRad);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
this.scratchEnu,
|
||||
this.scratchLocal,
|
||||
this.scratchForward,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward);
|
||||
|
||||
this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad);
|
||||
this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad);
|
||||
this.scratchLocal.z = Math.cos(pitchRad);
|
||||
Cesium.Matrix4.multiplyByPointAsVector(
|
||||
this.scratchEnu,
|
||||
this.scratchLocal,
|
||||
this.scratchUp,
|
||||
);
|
||||
Cesium.Cartesian3.normalize(this.scratchUp, this.scratchUp);
|
||||
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
this.scratchHorizontal,
|
||||
COCKPIT_FORWARD_OFFSET_M,
|
||||
this.scratchCamera,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
this.cockpitAnchor,
|
||||
this.scratchCamera,
|
||||
this.scratchCamera,
|
||||
);
|
||||
Cesium.Matrix4.getTranslation(this.scratchEnu, this.scratchTarget);
|
||||
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
this.scratchTarget,
|
||||
COCKPIT_UP_OFFSET_M,
|
||||
this.scratchTarget,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
this.scratchCamera,
|
||||
this.scratchTarget,
|
||||
this.scratchCamera,
|
||||
);
|
||||
|
||||
// Recheck at the final forward-offset camera coordinate because a taxiing
|
||||
// aircraft can cross into an adjacent coarse floor cell between updates.
|
||||
const cameraCartographic = Cesium.Cartographic.fromCartesian(
|
||||
this.scratchCamera,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchCameraCartographic,
|
||||
);
|
||||
if (cameraCartographic) {
|
||||
const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude);
|
||||
const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude);
|
||||
const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon);
|
||||
if (Number.isFinite(cameraFloorM)) {
|
||||
cockpitFloorM = Math.max(
|
||||
cockpitFloorM ?? Number.NEGATIVE_INFINITY,
|
||||
cameraFloorM,
|
||||
);
|
||||
}
|
||||
const safeHeightM = cockpitGroundSafeHeight(
|
||||
cameraCartographic.height,
|
||||
cockpitFloorM,
|
||||
COCKPIT_MIN_GROUND_CLEARANCE_M,
|
||||
);
|
||||
if (safeHeightM !== cameraCartographic.height) {
|
||||
cameraCartographic.height = safeHeightM;
|
||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(
|
||||
cameraCartographic,
|
||||
this.scratchCamera,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.viewer.camera.setView({
|
||||
destination: this.scratchCamera,
|
||||
orientation: { direction: this.scratchForward, up: this.scratchUp },
|
||||
});
|
||||
if (cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)) {
|
||||
this.lastHudUpdateMs = nowMs;
|
||||
this.updateHud(info, nowMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
formatCockpitContextScope,
|
||||
relativeBearing,
|
||||
resolveCockpitContextReadout,
|
||||
} from '../cockpitMath.js';
|
||||
import {
|
||||
cockpitUnitCase,
|
||||
formatCockpitDistance,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
/** The Context scope line in the active language (see cockpitMath.js). */
|
||||
const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${cockpitUnitCase(unitLabel('km'))}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
});
|
||||
|
||||
/** Hand a fresh copy of the Contact card to the view. */
|
||||
export function publishContextView() {
|
||||
const view = this.contextView;
|
||||
this.publishContext({
|
||||
...view,
|
||||
cohorts: Object.fromEntries(
|
||||
Object.entries(view.cohorts).map(([id, cohort]) => [id, { ...cohort }]),
|
||||
),
|
||||
nearest: { ...view.nearest },
|
||||
distance: { ...view.distance },
|
||||
direction: { ...view.direction },
|
||||
});
|
||||
}
|
||||
|
||||
/** Show or hide the Contact card; the HUD marks it with `context-active`. */
|
||||
export function showContextCard(visible) {
|
||||
if (this.presence.context === visible) return;
|
||||
this.presence.context = visible;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
export function updateContext(info, heading) {
|
||||
if (!this.context) return;
|
||||
const view = this.contextView;
|
||||
const snapshot =
|
||||
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
|
||||
const trackedId = info.icao24 || info.id;
|
||||
const readout = resolveCockpitContextReadout({ snapshot, info });
|
||||
if (!readout.visible) {
|
||||
this.showContextCard(false);
|
||||
this.contextLayoutStamp = null;
|
||||
this.pushCockpitSignal(
|
||||
'context-status',
|
||||
'info',
|
||||
t('CONTEXT STANDBY'),
|
||||
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showContextCard(true);
|
||||
const installationCoverage = snapshot.cohorts.find(
|
||||
(cohort) => cohort.id === 'military-installations',
|
||||
)?.coverage;
|
||||
view.subject = formatCockpitContextScope(
|
||||
snapshot.subject.label || trackedId,
|
||||
snapshot.radiusM,
|
||||
installationCoverage,
|
||||
LOCALIZED_CONTEXT_SCOPE,
|
||||
);
|
||||
// Navigation stays wired in every state — the operator must always be able
|
||||
// to step off the current contact from the panel that hosts the controls.
|
||||
view.canPrevious = Boolean(snapshot.navigation?.canPrevious);
|
||||
view.canNext = Boolean(snapshot.navigation?.canNext);
|
||||
|
||||
if (readout.contactLost) {
|
||||
// The subject left its source. Every number below is measured against a
|
||||
// position that stopped updating, so hold the last rendered readout and
|
||||
// say so instead of re-deriving stale geometry as if it were live.
|
||||
const enteringLost = view.state !== 'lost';
|
||||
view.state = 'lost';
|
||||
view.uncertainty = t(
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
||||
);
|
||||
this.publishContextView();
|
||||
// The cue changes the footer's height; re-run layout once on the way in
|
||||
// rather than every frame the contact stays lost.
|
||||
if (enteringLost) this.scheduleContextLayout();
|
||||
this.pushCockpitSignal(
|
||||
'context-status',
|
||||
'warning',
|
||||
t('CONTACT LOST · {subject}', {
|
||||
subject: snapshot.subject.label || snapshot.subject.id || 'SUBJECT',
|
||||
}),
|
||||
t('SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let unknownCount = 0;
|
||||
const nearest = [];
|
||||
for (const cohort of snapshot.cohorts) {
|
||||
const shown = view.cohorts[cohort.id];
|
||||
if (shown) {
|
||||
shown.count = cohort.count === null ? '?' : String(cohort.count);
|
||||
shown.unknown = cohort.relationship === 'UNKNOWN';
|
||||
}
|
||||
if (cohort.count === null) unknownCount += 1;
|
||||
for (const item of cohort.nearest) nearest.push({ ...item, cohort });
|
||||
}
|
||||
nearest.sort((a, b) => (a.distanceM ?? Infinity) - (b.distanceM ?? Infinity));
|
||||
const closest = nearest[0] || null;
|
||||
const closestLabel = this.services.formatAwarenessLabel(closest);
|
||||
const nearestLabel = closest
|
||||
? t('{cohort} · {contact}', {
|
||||
cohort: closest.cohort.label.toUpperCase(),
|
||||
contact: closestLabel,
|
||||
})
|
||||
: t('NO AVAILABLE EXAMPLE');
|
||||
view.nearest = {
|
||||
label: nearestLabel,
|
||||
ariaLabel:
|
||||
closest && closestLabel === '—'
|
||||
? t('{cohort}, Unavailable', { cohort: closest.cohort.label })
|
||||
: nearestLabel,
|
||||
};
|
||||
const distanceM = closest?.distanceM;
|
||||
const distance = Number.isFinite(distanceM)
|
||||
? formatCockpitDistance(distanceM)
|
||||
: '—';
|
||||
view.distance = {
|
||||
text: distance,
|
||||
ariaLabel: Number.isFinite(distanceM) ? distance : t('Unavailable'),
|
||||
};
|
||||
|
||||
// The arrow and BRG are nose-relative to the tracked aircraft. When the
|
||||
// subject is a vessel, an installation, or another aircraft, the rest of
|
||||
// this row is measured from that subject — so the aircraft-frame half is
|
||||
// dashed rather than presented alongside subject-frame distances as if the
|
||||
// two shared an origin.
|
||||
let relative = null;
|
||||
if (
|
||||
readout.aircraftRelative &&
|
||||
closest?.position &&
|
||||
Number.isFinite(info.latitude) &&
|
||||
Number.isFinite(info.longitude)
|
||||
) {
|
||||
const cartographic = Cesium.Cartographic.fromCartesian(closest.position);
|
||||
const bearing = cartographic
|
||||
? bearingBetweenCoordinates(
|
||||
info.latitude,
|
||||
info.longitude,
|
||||
Cesium.Math.toDegrees(cartographic.latitude),
|
||||
Cesium.Math.toDegrees(cartographic.longitude),
|
||||
)
|
||||
: null;
|
||||
relative = relativeBearing(bearing, heading);
|
||||
}
|
||||
view.direction = { angle: relative ?? 0, unknown: relative === null };
|
||||
if (relative === null) view.bearing = t('BRG —');
|
||||
else if (Math.abs(relative) < 8) view.bearing = t('AHEAD');
|
||||
else {
|
||||
view.bearing = t('{side} {angle}', {
|
||||
side: relative < 0 ? t('L') : t('R'),
|
||||
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
|
||||
});
|
||||
}
|
||||
view.uncertainty = unknownCount
|
||||
? tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
||||
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
|
||||
)
|
||||
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
|
||||
view.updated = Number.isFinite(snapshot.evaluatedAt)
|
||||
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
|
||||
: '--:--:--Z';
|
||||
view.state = unknownCount ? 'uncertain' : 'current';
|
||||
this.publishContextView();
|
||||
this.updateCockpitSignals(snapshot, unknownCount);
|
||||
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
|
||||
this.contextLayoutStamp = snapshot.evaluatedAt;
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// src/ui/cockpitContext.test.mjs — the Cockpit Contact card renders what the
|
||||
// Cockpit controller presents, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { setLocale, t as translate } from '../i18n/index.js';
|
||||
import {
|
||||
publishContextView,
|
||||
showContextCard,
|
||||
updateContext,
|
||||
} from './cockpitContext.js';
|
||||
import { createContextState } from './cockpitContextState.js';
|
||||
import { syncWeatherToggle } from './cockpitInstruments.js';
|
||||
import { setContextCollapsed } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitContext from './views/panels/CockpitContext.vue';
|
||||
|
||||
function fixture(t, snapshot = null) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitContext).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useCockpitStore(pinia);
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const signals = [];
|
||||
const layouts = [];
|
||||
// The Contact presenter needs only this much of the controller.
|
||||
const owner = {
|
||||
context: $('#cockpit-context'),
|
||||
presence: { context: false },
|
||||
contextView: createContextState(),
|
||||
contextLayoutStamp: null,
|
||||
publishContext: (view) => store.publishContext(view),
|
||||
publishContextView,
|
||||
publishPresenceView: () => store.publishPresence({ ...owner.presence }),
|
||||
showContextCard,
|
||||
pushCockpitSignal: (...args) => signals.push(args),
|
||||
updateCockpitSignals() {},
|
||||
scheduleContextLayout: () => layouts.push(true),
|
||||
services: {
|
||||
militaryAwarenessLayer: { getContextSnapshot: () => owner.snapshot },
|
||||
formatAwarenessLabel: (item) => item?.id ?? '—',
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
const text = (selector) => $(selector).textContent.trim();
|
||||
return { $, text, owner, signals, layouts };
|
||||
}
|
||||
|
||||
const INFO = {
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
};
|
||||
|
||||
const snapshot = (overrides = {}) => ({
|
||||
subject: { layerId: 'military', id: 'm1', label: 'RCH123' },
|
||||
radiusM: 250_000,
|
||||
evaluatedAt: Date.UTC(2026, 9, 31, 12, 34, 56),
|
||||
navigation: { canPrevious: false, canNext: true },
|
||||
cohorts: [
|
||||
{
|
||||
id: 'flights',
|
||||
label: 'Flights',
|
||||
count: 12,
|
||||
relationship: 'OBSERVED',
|
||||
nearest: [{ id: 'N1', distanceM: 4200 }],
|
||||
},
|
||||
{
|
||||
id: 'military',
|
||||
label: 'Military',
|
||||
count: null,
|
||||
relationship: 'UNKNOWN',
|
||||
nearest: [],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('before the controller presents, the card shows the state it starts in', (t) => {
|
||||
const { $, text } = fixture(t);
|
||||
const card = $('#cockpit-context');
|
||||
assert.equal(card.hidden, true);
|
||||
assert.equal(card.hasAttribute('data-state'), false);
|
||||
assert.equal(text('#cockpit-context-subject'), 'CONTACTS · 250 KM');
|
||||
assert.equal($('#cockpit-context-previous').disabled, false);
|
||||
assert.equal($('#cockpit-context-next').disabled, false);
|
||||
assert.deepEqual(
|
||||
[...document.querySelectorAll('[data-context-cohort] strong')].map(
|
||||
(count) => count.textContent,
|
||||
),
|
||||
['—', '—', '—', '—'],
|
||||
);
|
||||
assert.equal(text('#cockpit-context-nearest-label'), 'NO AVAILABLE EXAMPLE');
|
||||
assert.equal(
|
||||
$('#cockpit-context-nearest-label').hasAttribute('aria-label'),
|
||||
false,
|
||||
);
|
||||
assert.equal($('#cockpit-context-direction').getAttribute('style'), null);
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR',
|
||||
);
|
||||
assert.equal(text('#cockpit-context-updated'), '--:--:--Z');
|
||||
const weather = $('#cockpit-weather-toggle');
|
||||
assert.equal(weather.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(
|
||||
weather.getAttribute('aria-label'),
|
||||
'Enable cockpit weather effects',
|
||||
);
|
||||
assert.equal(weather.title, 'Enable cockpit weather effects');
|
||||
assert.equal(text('#cockpit-weather-state'), 'OFF');
|
||||
});
|
||||
|
||||
test('a snapshot fills the card and keeps classes other code sets', async (t) => {
|
||||
const { $, text, owner, layouts } = fixture(t, snapshot());
|
||||
const flights = $('[data-context-cohort="flights"]');
|
||||
flights.classList.add('voice-highlight');
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
const card = $('#cockpit-context');
|
||||
assert.equal(card.hidden, false);
|
||||
assert.equal(card.dataset.state, 'uncertain');
|
||||
assert.equal(owner.presence.context, true);
|
||||
assert.match(
|
||||
text('#cockpit-context-subject'),
|
||||
/^RCH123 · 250\u00a0KM AIR\/SEA WINDOW$/,
|
||||
);
|
||||
assert.equal($('#cockpit-context-previous').disabled, true);
|
||||
assert.equal($('#cockpit-context-next').disabled, false);
|
||||
assert.equal(flights.querySelector('strong').textContent, '12');
|
||||
assert.equal(flights.classList.contains('unknown'), false);
|
||||
assert.equal(flights.classList.contains('voice-highlight'), true);
|
||||
const military = $('[data-context-cohort="military"]');
|
||||
assert.equal(military.querySelector('strong').textContent, '?');
|
||||
assert.equal(military.classList.contains('unknown'), true);
|
||||
assert.equal(text('#cockpit-context-nearest-label'), 'FLIGHTS · N1');
|
||||
assert.equal(
|
||||
$('#cockpit-context-nearest-label').getAttribute('aria-label'),
|
||||
'FLIGHTS · N1',
|
||||
);
|
||||
assert.match(text('#cockpit-context-distance'), /^4\.2/);
|
||||
// The tracked aircraft is not the subject: the nose-relative half dashes.
|
||||
assert.equal(text('#cockpit-context-bearing'), 'BRG —');
|
||||
const direction = $('#cockpit-context-direction');
|
||||
assert.equal(direction.style.transform, 'rotate(0deg)');
|
||||
assert.equal(direction.classList.contains('unknown'), true);
|
||||
assert.equal(direction.classList.contains('material-symbols-outlined'), true);
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'1 INPUT UNKNOWN · NOT AN ALL-CLEAR',
|
||||
);
|
||||
assert.equal(text('#cockpit-context-updated'), '12:34:56Z');
|
||||
assert.equal(layouts.length, 1);
|
||||
});
|
||||
|
||||
test('a lost contact holds its readout behind the CONTACT LOST cue', async (t) => {
|
||||
const { $, text, owner, layouts } = fixture(t, snapshot());
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
const readout = () =>
|
||||
[
|
||||
'#cockpit-context-nearest-label',
|
||||
'#cockpit-context-distance',
|
||||
'#cockpit-context-bearing',
|
||||
'#cockpit-context-updated',
|
||||
].map(text);
|
||||
const held = readout();
|
||||
owner.snapshot = snapshot({
|
||||
subjectPresent: false,
|
||||
navigation: { canPrevious: true, canNext: false },
|
||||
cohorts: [{ id: 'flights', label: 'Flights', count: 1, nearest: [] }],
|
||||
});
|
||||
updateContext.call(owner, INFO, 0);
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').dataset.state, 'lost');
|
||||
assert.equal(
|
||||
text('#cockpit-context-uncertainty'),
|
||||
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
|
||||
);
|
||||
// Navigation follows the lost snapshot; the readout holds.
|
||||
assert.deepEqual(readout(), held);
|
||||
assert.equal($('#cockpit-context-previous').disabled, false);
|
||||
assert.equal($('#cockpit-context-next').disabled, true);
|
||||
assert.equal($('[data-context-cohort="flights"] strong').textContent, '12');
|
||||
// Layout reruns once on the way in, not every update.
|
||||
assert.equal(layouts.length, 2);
|
||||
});
|
||||
|
||||
test('without a snapshot the card stands by, hidden', async (t) => {
|
||||
const { $, owner, signals } = fixture(t, snapshot());
|
||||
updateContext.call(owner, INFO, 0);
|
||||
owner.snapshot = null;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').hidden, true);
|
||||
assert.equal(owner.presence.context, false);
|
||||
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
|
||||
});
|
||||
|
||||
test('the weather toggle renders its state and the action it offers', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
syncWeatherToggle.call(owner, true);
|
||||
await nextTick();
|
||||
const weather = $('#cockpit-weather-toggle');
|
||||
assert.equal(weather.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(
|
||||
weather.getAttribute('aria-label'),
|
||||
'Disable cockpit weather effects',
|
||||
);
|
||||
assert.equal(weather.title, 'Disable cockpit weather effects');
|
||||
assert.equal(text('#cockpit-weather-state'), 'ON');
|
||||
assert.equal(owner.contextView.weather, true);
|
||||
});
|
||||
|
||||
test('re-renders keep the collapse the controller presents and leave it the layout', async (t) => {
|
||||
const { $, owner } = fixture(t, snapshot());
|
||||
updateContext.call(owner, INFO, 0);
|
||||
const card = $('#cockpit-context');
|
||||
owner.presence.contextCollapsed = true;
|
||||
owner.publishPresenceView();
|
||||
card.dataset.layoutMode = 'compact-bottom';
|
||||
owner.snapshot = snapshot({ evaluatedAt: Date.UTC(2026, 9, 31, 12, 35) });
|
||||
updateContext.call(owner, INFO, 0);
|
||||
await nextTick();
|
||||
assert.equal(card.hidden, false);
|
||||
assert.equal(card.dataset.collapsed, 'true');
|
||||
assert.equal(card.dataset.layoutMode, 'compact-bottom');
|
||||
const toggle = $('#cockpit-context-toggle');
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(toggle.getAttribute('aria-label'), 'Expand Contact panel');
|
||||
assert.equal(toggle.title, 'Expand contact panel');
|
||||
assert.equal(toggle.textContent.trim(), 'chevron_right');
|
||||
});
|
||||
|
||||
test('the card is labelled in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, text, owner } = fixture(t);
|
||||
const subject = translate('CONTACTS · 250 KM');
|
||||
assert.notEqual(subject, 'CONTACTS · 250 KM');
|
||||
assert.equal(text('#cockpit-context-subject'), subject);
|
||||
assert.equal(text('#cockpit-weather-state'), translate('OFF'));
|
||||
syncWeatherToggle.call(owner, true);
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-weather-state'), translate('ON'));
|
||||
assert.equal(
|
||||
$('#cockpit-weather-toggle').getAttribute('aria-label'),
|
||||
translate('Disable cockpit weather effects'),
|
||||
);
|
||||
});
|
||||
|
||||
test('collapsing the card publishes once per change', async (t) => {
|
||||
const { $, owner } = fixture(t);
|
||||
let published = 0;
|
||||
const publish = owner.publishPresenceView;
|
||||
Object.assign(owner, {
|
||||
contextCollapsed: false,
|
||||
presence: { context: true, contextCollapsed: false },
|
||||
publishPresenceView: () => {
|
||||
published += 1;
|
||||
publish();
|
||||
},
|
||||
});
|
||||
setContextCollapsed.call(owner, true);
|
||||
setContextCollapsed.call(owner, true);
|
||||
assert.equal(published, 1);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').dataset.collapsed, 'true');
|
||||
assert.equal(
|
||||
$('#cockpit-context-toggle').getAttribute('aria-expanded'),
|
||||
'false',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The Cockpit Contact card's state as it first renders. It needs only the
|
||||
* i18n module, so the controller and the view can both read it.
|
||||
*/
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Cohorts the card counts, in its order. */
|
||||
const COCKPIT_CONTEXT_COHORTS = Object.freeze([
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
]);
|
||||
|
||||
/**
|
||||
* The card before any snapshot: navigation on, counts and readouts empty,
|
||||
* weather off. The controller keeps it, changes it in place and publishes a
|
||||
* copy; `CockpitContext` renders it.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createContextState() {
|
||||
return {
|
||||
state: null,
|
||||
subject: t('CONTACTS · 250 KM'),
|
||||
canPrevious: true,
|
||||
canNext: true,
|
||||
cohorts: Object.fromEntries(
|
||||
COCKPIT_CONTEXT_COHORTS.map((id) => [id, { count: '—', unknown: false }]),
|
||||
),
|
||||
nearest: { label: t('NO AVAILABLE EXAMPLE'), ariaLabel: null },
|
||||
distance: { text: '—', ariaLabel: null },
|
||||
direction: { angle: null, unknown: false },
|
||||
bearing: '—',
|
||||
uncertainty: t('AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR'),
|
||||
updated: '--:--:--Z',
|
||||
weather: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
|
||||
import {
|
||||
readAircraftInfo,
|
||||
dispatchCockpitModeChanged,
|
||||
toggleTrackedTr3b,
|
||||
syncTr3bToggle,
|
||||
syncEntry,
|
||||
publishPresenceView,
|
||||
navigateContext,
|
||||
_adoptTrackedEntity,
|
||||
enter,
|
||||
exit,
|
||||
} from './cockpitTrackingController.js';
|
||||
import { update } from './cockpitCamera.js';
|
||||
import {
|
||||
publishInstrumentsView,
|
||||
publishRouteView,
|
||||
updateHud,
|
||||
updateRoute,
|
||||
syncWeatherToggle,
|
||||
setVisionMode,
|
||||
cycleVisionMode,
|
||||
clearPredictiveRoute,
|
||||
} from './cockpitInstruments.js';
|
||||
import {
|
||||
publishContextView,
|
||||
showContextCard,
|
||||
updateContext,
|
||||
} from './cockpitContext.js';
|
||||
import { createContextState } from './cockpitContextState.js';
|
||||
import { createBriefState } from './cockpitBriefPages.js';
|
||||
import {
|
||||
createInstrumentState,
|
||||
createRouteState,
|
||||
createVisionState,
|
||||
} from './cockpitInstrumentState.js';
|
||||
import { onLocaleChange } from '../i18n/index.js';
|
||||
import {
|
||||
publishBriefView,
|
||||
showBriefPage,
|
||||
setBriefAutoRotate,
|
||||
startBriefRotation,
|
||||
stopBriefRotation,
|
||||
updateLocalPosition,
|
||||
maybeRefreshRegionalBrief,
|
||||
renderRegionalBriefStatus,
|
||||
renderRegionalBrief,
|
||||
relocalizeBrief,
|
||||
} from './cockpitBriefing.js';
|
||||
import {
|
||||
handleSignalClick,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
updateCockpitSignals,
|
||||
} from './cockpitSignals.js';
|
||||
import {
|
||||
scheduleContextLayout,
|
||||
setContextCollapsed,
|
||||
setSignalCollapsed,
|
||||
syncContextLayout,
|
||||
syncSignalLayout,
|
||||
} from './cockpitLayout.js';
|
||||
import { onKeyDown } from './cockpitInput.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export class CockpitViewController {
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
services,
|
||||
onVisionChange = null,
|
||||
getInitialVisionMode = null,
|
||||
onCameraTakeover = null,
|
||||
isEntryAllowed = null,
|
||||
isUtilityDisclosureOpen = null,
|
||||
onEntered = null,
|
||||
onExited = null,
|
||||
restoreTrackingFrame = null,
|
||||
publish = {},
|
||||
} = {},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this.services = services;
|
||||
this.destroyed = false;
|
||||
this.disposed = false;
|
||||
this.active = false;
|
||||
this.trackedEntity = null;
|
||||
this.trackedEntityWasShown = true;
|
||||
this.heading = null;
|
||||
this.lastFrameMs = 0;
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.lastHudUpdateMs = 0;
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.lastGroundProbeMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
this.entry = document.getElementById('cockpit-entry');
|
||||
this.tr3bToggle = document.getElementById('tr3b-toggle');
|
||||
this._tr3bSignature = null;
|
||||
this.mapViewButton = document.getElementById('map-view-switch');
|
||||
this.hud = document.getElementById('cockpit-hud');
|
||||
this.entryFocusOrigin = null;
|
||||
this.visionPrevious = document.getElementById('cockpit-vision-previous');
|
||||
this.visionCurrent = document.getElementById('cockpit-vision-current');
|
||||
this.visionNext = document.getElementById('cockpit-vision-next');
|
||||
this.visionMode = 'optical';
|
||||
this.onVisionChange = onVisionChange;
|
||||
this.getInitialVisionMode =
|
||||
typeof getInitialVisionMode === 'function'
|
||||
? getInitialVisionMode
|
||||
: () => 'optical';
|
||||
this.onCameraTakeover = onCameraTakeover;
|
||||
this.onEntered = onEntered;
|
||||
this.onExited = onExited;
|
||||
this.restoreTrackingFrame =
|
||||
typeof restoreTrackingFrame === 'function'
|
||||
? restoreTrackingFrame
|
||||
: () => false;
|
||||
this.isEntryAllowed =
|
||||
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
|
||||
this.isUtilityDisclosureOpen =
|
||||
typeof isUtilityDisclosureOpen === 'function'
|
||||
? isUtilityDisclosureOpen
|
||||
: () => false;
|
||||
this.context = document.getElementById('cockpit-context');
|
||||
this.contextPrevious = document.getElementById('cockpit-context-previous');
|
||||
this.contextNext = document.getElementById('cockpit-context-next');
|
||||
this.contextToggle = document.getElementById('cockpit-context-toggle');
|
||||
this.weatherToggle = document.getElementById('cockpit-weather-toggle');
|
||||
this.contextCollapsed = false;
|
||||
this.signalStream = document.getElementById('cockpit-signal-stream');
|
||||
this.signalList = document.getElementById('cockpit-signal-list');
|
||||
this.signalToggle = document.getElementById('cockpit-signal-toggle');
|
||||
this.briefPrevious = document.getElementById('cockpit-brief-previous');
|
||||
this.briefNext = document.getElementById('cockpit-brief-next');
|
||||
this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
|
||||
this.briefTabs = Array.from(
|
||||
document.querySelectorAll('[data-cockpit-brief-index]'),
|
||||
);
|
||||
this.signalCollapsed = false;
|
||||
this.signalUserCollapsed = false;
|
||||
this.signalItems = [];
|
||||
this.signalSignatures = new Map();
|
||||
this.briefPageIndex = 0;
|
||||
this.briefAutoRotateEnabled = false;
|
||||
// Cockpit's views render what these receive.
|
||||
this.publishPresence = publish.presence ?? (() => {});
|
||||
this.publishBrief = publish.brief ?? (() => {});
|
||||
this.publishContext = publish.context ?? (() => {});
|
||||
this.publishInstruments = publish.instruments ?? (() => {});
|
||||
this.publishRoute = publish.route ?? (() => {});
|
||||
this.publishVision = publish.vision ?? (() => {});
|
||||
this.publishSignals = publish.signals ?? (() => {});
|
||||
this.publishUtilityLayout = publish.utilityLayout ?? (() => {});
|
||||
this.presence = {
|
||||
active: false,
|
||||
entryAvailable: false,
|
||||
tr3b: null,
|
||||
context: false,
|
||||
// The Contact and briefing cards' collapse, which their toggles show.
|
||||
contextCollapsed: false,
|
||||
signalCollapsed: false,
|
||||
};
|
||||
this.instruments = createInstrumentState();
|
||||
this.routeView = createRouteState();
|
||||
this.routeSignature = null;
|
||||
this.contextView = createContextState();
|
||||
this.brief = createBriefState();
|
||||
this.briefTimer = null;
|
||||
this.lastAircraftInfo = null;
|
||||
this.regionalBrief = null;
|
||||
this.regionalBriefAnchor = null;
|
||||
this.regionalBriefFetchedAt = 0;
|
||||
this.regionalBriefAbort = null;
|
||||
this.regionalBriefRequestToken = 0;
|
||||
this.regionalBriefSubjectId = null;
|
||||
this.contextLayoutFrame = null;
|
||||
this.contextLayoutStamp = null;
|
||||
this.scratchTarget = new Cesium.Cartesian3();
|
||||
this.cockpitAnchor = new Cesium.Cartesian3();
|
||||
this.cockpitAnchorValid = false;
|
||||
this.scratchCamera = new Cesium.Cartesian3();
|
||||
this.scratchAdvance = new Cesium.Cartesian3();
|
||||
this.scratchCorrection = new Cesium.Cartesian3();
|
||||
this.scratchForward = new Cesium.Cartesian3();
|
||||
this.scratchHorizontal = new Cesium.Cartesian3();
|
||||
this.scratchUp = new Cesium.Cartesian3();
|
||||
this.scratchLocal = new Cesium.Cartesian3();
|
||||
this.scratchEnu = new Cesium.Matrix4();
|
||||
this.scratchAnchorCartographic = new Cesium.Cartographic();
|
||||
this.scratchCameraCartographic = new Cesium.Cartographic();
|
||||
this.scratchTargetCartographic = new Cesium.Cartographic();
|
||||
this._listenerRemovers = [onLocaleChange(() => this.relocalize())];
|
||||
|
||||
// Camera mutations belong before scene update/culling. Changing the camera
|
||||
// from preRender makes 3D Tiles discover a new view after traversal and can
|
||||
// create a self-sustaining refinement loop under a moving cockpit camera.
|
||||
this._listenerRemovers.push(
|
||||
viewer.scene.preUpdate.addEventListener(() => this.update()),
|
||||
viewer.trackedEntityChanged.addEventListener(() => {
|
||||
if (this.active) this._adoptTrackedEntity(performance.now());
|
||||
else this.syncEntry();
|
||||
}),
|
||||
);
|
||||
this._listen(this.entry, 'click', () => this.enter());
|
||||
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
|
||||
this._listen(this.mapViewButton, 'click', () => this.exit());
|
||||
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
|
||||
this._listen(this.visionCurrent, 'click', () => this.cycleVisionMode(1));
|
||||
this._listen(this.visionNext, 'click', () => this.cycleVisionMode(1));
|
||||
this._listen(this.contextPrevious, 'click', () =>
|
||||
this.navigateContext(-1, { origin: 'user' }),
|
||||
);
|
||||
this._listen(this.contextNext, 'click', () =>
|
||||
this.navigateContext(1, { origin: 'user' }),
|
||||
);
|
||||
this._listen(this.contextToggle, 'click', () =>
|
||||
this.setContextCollapsed(!this.contextCollapsed),
|
||||
);
|
||||
this._listen(this.weatherToggle, 'click', () => {
|
||||
const enabled = !this.contextView.weather;
|
||||
this.syncWeatherToggle(enabled);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-weather-toggle', {
|
||||
detail: { enabled },
|
||||
}),
|
||||
);
|
||||
});
|
||||
this._listen(window, 'gev:cockpit-weather-state', (event) => {
|
||||
this.syncWeatherToggle(event?.detail?.enabled !== false);
|
||||
});
|
||||
this._listen(this.signalToggle, 'click', () =>
|
||||
this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
|
||||
);
|
||||
this._listen(this.signalList, 'click', (event) =>
|
||||
this.handleSignalClick(event),
|
||||
);
|
||||
this._listen(this.briefPrevious, 'click', () =>
|
||||
this.showBriefPage(this.briefPageIndex - 1, { manual: true }),
|
||||
);
|
||||
this._listen(this.briefNext, 'click', () =>
|
||||
this.showBriefPage(this.briefPageIndex + 1, { manual: true }),
|
||||
);
|
||||
this._listen(this.briefAutoToggle, 'click', () => {
|
||||
this.setBriefAutoRotate(!this.briefAutoRotateEnabled);
|
||||
});
|
||||
this.briefTabs.forEach((button) =>
|
||||
this._listen(button, 'click', () => {
|
||||
this.showBriefPage(Number(button.dataset.cockpitBriefIndex), {
|
||||
manual: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
this._listen(document, 'visibilitychange', () => {
|
||||
if (document.hidden) this.stopBriefRotation();
|
||||
else if (this.briefAutoRotateEnabled) this.startBriefRotation();
|
||||
});
|
||||
this._listen(window, 'resize', () => this.scheduleContextLayout());
|
||||
this._listen(document, 'keydown', (event) => this.onKeyDown(event), true);
|
||||
}
|
||||
|
||||
_listen(target, type, handler, options) {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(type, handler, options);
|
||||
this._listenerRemovers.push(() =>
|
||||
target.removeEventListener(type, handler, options),
|
||||
);
|
||||
}
|
||||
|
||||
readAircraftInfo() {
|
||||
return readAircraftInfo.call(this);
|
||||
}
|
||||
dispatchCockpitModeChanged(active, info) {
|
||||
return dispatchCockpitModeChanged.call(this, active, info);
|
||||
}
|
||||
toggleTrackedTr3b() {
|
||||
return toggleTrackedTr3b.call(this);
|
||||
}
|
||||
publishPresenceView() {
|
||||
return publishPresenceView.call(this);
|
||||
}
|
||||
syncTr3bToggle(info) {
|
||||
return syncTr3bToggle.call(this, info);
|
||||
}
|
||||
syncEntry() {
|
||||
return syncEntry.call(this);
|
||||
}
|
||||
navigateContext(direction, options) {
|
||||
return navigateContext.call(this, direction, options);
|
||||
}
|
||||
_adoptTrackedEntity(nowMs, suppliedInfo) {
|
||||
return _adoptTrackedEntity.call(this, nowMs, suppliedInfo);
|
||||
}
|
||||
enter() {
|
||||
return enter.call(this);
|
||||
}
|
||||
exit(options) {
|
||||
return exit.call(this, options);
|
||||
}
|
||||
update() {
|
||||
return update.call(this);
|
||||
}
|
||||
publishInstrumentsView() {
|
||||
return publishInstrumentsView.call(this);
|
||||
}
|
||||
publishRouteView() {
|
||||
return publishRouteView.call(this);
|
||||
}
|
||||
updateHud(info, nowMs, forceContext) {
|
||||
return updateHud.call(this, info, nowMs, forceContext);
|
||||
}
|
||||
updateRoute(info) {
|
||||
return updateRoute.call(this, info);
|
||||
}
|
||||
syncWeatherToggle(enabled) {
|
||||
return syncWeatherToggle.call(this, enabled);
|
||||
}
|
||||
setVisionMode(mode, options) {
|
||||
return setVisionMode.call(this, mode, options);
|
||||
}
|
||||
cycleVisionMode(direction) {
|
||||
return cycleVisionMode.call(this, direction);
|
||||
}
|
||||
clearPredictiveRoute() {
|
||||
return clearPredictiveRoute.call(this);
|
||||
}
|
||||
publishContextView() {
|
||||
return publishContextView.call(this);
|
||||
}
|
||||
showContextCard(visible) {
|
||||
return showContextCard.call(this, visible);
|
||||
}
|
||||
updateContext(info, heading) {
|
||||
return updateContext.call(this, info, heading);
|
||||
}
|
||||
publishBriefView() {
|
||||
return publishBriefView.call(this);
|
||||
}
|
||||
showBriefPage(index, options) {
|
||||
return showBriefPage.call(this, index, options);
|
||||
}
|
||||
setBriefAutoRotate(enabled) {
|
||||
return setBriefAutoRotate.call(this, enabled);
|
||||
}
|
||||
startBriefRotation(options) {
|
||||
return startBriefRotation.call(this, options);
|
||||
}
|
||||
stopBriefRotation() {
|
||||
return stopBriefRotation.call(this);
|
||||
}
|
||||
updateLocalPosition(info) {
|
||||
return updateLocalPosition.call(this, info);
|
||||
}
|
||||
maybeRefreshRegionalBrief(info) {
|
||||
return maybeRefreshRegionalBrief.call(this, info);
|
||||
}
|
||||
renderRegionalBriefStatus(status, info) {
|
||||
return renderRegionalBriefStatus.call(this, status, info);
|
||||
}
|
||||
relocalizeBrief() {
|
||||
return relocalizeBrief.call(this);
|
||||
}
|
||||
renderRegionalBrief(payload, info) {
|
||||
return renderRegionalBrief.call(this, payload, info);
|
||||
}
|
||||
publishCockpitSignals() {
|
||||
return publishCockpitSignals.call(this);
|
||||
}
|
||||
pushCockpitSignal(key, tone, title, detail, target) {
|
||||
return pushCockpitSignal.call(this, key, tone, title, detail, target);
|
||||
}
|
||||
updateCockpitSignals(snapshot, unknownCount) {
|
||||
return updateCockpitSignals.call(this, snapshot, unknownCount);
|
||||
}
|
||||
scheduleContextLayout() {
|
||||
return scheduleContextLayout.call(this);
|
||||
}
|
||||
setContextCollapsed(collapsed) {
|
||||
return setContextCollapsed.call(this, collapsed);
|
||||
}
|
||||
setSignalCollapsed(collapsed, options) {
|
||||
return setSignalCollapsed.call(this, collapsed, options);
|
||||
}
|
||||
syncContextLayout() {
|
||||
return syncContextLayout.call(this);
|
||||
}
|
||||
syncSignalLayout() {
|
||||
return syncSignalLayout.call(this);
|
||||
}
|
||||
onKeyDown(event) {
|
||||
return onKeyDown.call(this, event);
|
||||
}
|
||||
|
||||
handleSignalClick(event) {
|
||||
return handleSignalClick.call(this, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Word every Cockpit surface again in the current locale. In flight the
|
||||
* last reading is shown again; otherwise the surfaces go back to their
|
||||
* placeholders, which the next entry replaces.
|
||||
*/
|
||||
relocalize() {
|
||||
if (this.destroyed) return;
|
||||
this.relocalizeBrief();
|
||||
this.publishVision(createVisionState(this.visionMode));
|
||||
this.routeSignature = null;
|
||||
if (this.active && this.lastAircraftInfo) {
|
||||
this.rewordingSignals = true;
|
||||
try {
|
||||
this.updateHud(this.lastAircraftInfo, performance.now(), true);
|
||||
} finally {
|
||||
this.rewordingSignals = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.instruments = createInstrumentState();
|
||||
this.publishInstrumentsView();
|
||||
this.routeView = createRouteState();
|
||||
this.publishRouteView();
|
||||
this.contextView = {
|
||||
...createContextState(),
|
||||
weather: this.contextView.weather,
|
||||
};
|
||||
this.publishContextView();
|
||||
}
|
||||
|
||||
/** Revoke new work before the shell awaits layer restoration. */
|
||||
stop() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.regionalBriefAbort?.abort();
|
||||
this.regionalBriefAbort = null;
|
||||
this.regionalBriefRequestToken += 1;
|
||||
this.stopBriefRotation();
|
||||
if (this.contextLayoutFrame !== null)
|
||||
cancelAnimationFrame(this.contextLayoutFrame);
|
||||
this.contextLayoutFrame = null;
|
||||
for (const removeListener of this._listenerRemovers.splice(0))
|
||||
removeListener?.();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
this.stop();
|
||||
this.exit({ restoreTracking: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CockpitViewController } from './cockpitController.js';
|
||||
|
||||
function environment() {
|
||||
const original = { document: globalThis.document, window: globalThis.window };
|
||||
const document = Object.assign(new EventTarget(), {
|
||||
getElementById: () => null,
|
||||
querySelectorAll: () => [],
|
||||
body: { classList: { remove() {} } },
|
||||
});
|
||||
const window = Object.assign(new EventTarget(), { clearTimeout, setTimeout });
|
||||
globalThis.document = document;
|
||||
globalThis.window = window;
|
||||
const callbacks = new Set();
|
||||
const event = {
|
||||
addEventListener(fn) {
|
||||
callbacks.add(fn);
|
||||
return () => callbacks.delete(fn);
|
||||
},
|
||||
};
|
||||
const viewer = {
|
||||
scene: {
|
||||
preUpdate: event,
|
||||
screenSpaceCameraController: { enableInputs: false },
|
||||
},
|
||||
trackedEntityChanged: event,
|
||||
entities: { contains: () => false },
|
||||
};
|
||||
return {
|
||||
viewer,
|
||||
callbacks,
|
||||
restore() {
|
||||
Object.assign(globalThis, original);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Cockpit subscriptions are released once and retained actions cannot restart it', () => {
|
||||
const env = environment();
|
||||
try {
|
||||
const owner = new CockpitViewController(env.viewer, { services: {} });
|
||||
assert.equal(env.callbacks.size, 2);
|
||||
owner.dispose();
|
||||
owner.dispose();
|
||||
assert.equal(env.callbacks.size, 0);
|
||||
assert.equal(owner.enter(), false);
|
||||
assert.equal(owner.navigateContext(1), false);
|
||||
assert.equal(owner.toggleTrackedTr3b(), false);
|
||||
assert.equal(owner.update(), false);
|
||||
assert.equal(owner.active, false);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('disposing an active Cockpit releases the supplied render owner without retracking', () => {
|
||||
const env = environment();
|
||||
try {
|
||||
const released = [];
|
||||
const owner = new CockpitViewController(env.viewer, {
|
||||
services: { releaseContinuousRender: (reason) => released.push(reason) },
|
||||
});
|
||||
owner.active = true;
|
||||
owner.dispose();
|
||||
owner.dispose();
|
||||
assert.deepEqual(released, ['cockpit']);
|
||||
assert.equal(
|
||||
env.viewer.scene.screenSpaceCameraController.enableInputs,
|
||||
true,
|
||||
);
|
||||
assert.equal(env.viewer.trackedEntity, undefined);
|
||||
assert.equal(owner.active, false);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('aircraft identity is read from the supplied layer instances', () => {
|
||||
const env = environment();
|
||||
try {
|
||||
const civilian = {
|
||||
icao24: 'abc123',
|
||||
layerId: 'flights',
|
||||
callsign: 'CIVIL',
|
||||
};
|
||||
const military = {
|
||||
icao24: 'def456',
|
||||
layerId: 'military',
|
||||
callsign: 'MILITARY',
|
||||
};
|
||||
const owner = new CockpitViewController(env.viewer, {
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: () => civilian },
|
||||
militaryFlightsLayer: { getTrackedInfo: () => military },
|
||||
},
|
||||
});
|
||||
env.viewer.trackedEntity = { gevTrackedId: 'military:def456' };
|
||||
assert.deepEqual(owner.readAircraftInfo(), military);
|
||||
env.viewer.trackedEntity = { gevTrackedId: 'flights:abc123' };
|
||||
assert.deepEqual(owner.readAircraftInfo(), civilian);
|
||||
owner.dispose();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an obsolete regional briefing cannot publish after Cockpit disposal', async () => {
|
||||
const env = environment();
|
||||
try {
|
||||
let finish;
|
||||
let signal;
|
||||
const owner = new CockpitViewController(env.viewer, {
|
||||
services: {
|
||||
regionalDistanceM: () => Infinity,
|
||||
fetchRegionalBrief: (_lat, _lon, options) => {
|
||||
signal = options.signal;
|
||||
return new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
},
|
||||
releaseContinuousRender() {},
|
||||
},
|
||||
});
|
||||
const published = [];
|
||||
owner.renderRegionalBrief = (payload) => published.push(payload);
|
||||
owner.active = true;
|
||||
owner.maybeRefreshRegionalBrief({
|
||||
layerId: 'flights',
|
||||
icao24: 'abc123',
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
});
|
||||
owner.dispose();
|
||||
assert.equal(signal.aborted, true);
|
||||
finish({ articles: [] });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(published, []);
|
||||
assert.equal(owner.regionalBriefAbort, null);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('stopping Cockpit revokes camera actions before deferred disposal releases rendering', () => {
|
||||
const env = environment();
|
||||
try {
|
||||
const released = [];
|
||||
const owner = new CockpitViewController(env.viewer, {
|
||||
services: {
|
||||
releaseContinuousRender: (reason) => released.push(reason),
|
||||
},
|
||||
});
|
||||
owner.active = true;
|
||||
owner.stop();
|
||||
assert.equal(env.callbacks.size, 0);
|
||||
assert.equal(owner.update(), false);
|
||||
assert.equal(owner.navigateContext(1), false);
|
||||
assert.deepEqual(released, []);
|
||||
owner.dispose();
|
||||
assert.deepEqual(released, ['cockpit']);
|
||||
assert.equal(owner.active, false);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('stopped Cockpit cannot rearm briefing rotation or request a fresh region', () => {
|
||||
const env = environment();
|
||||
try {
|
||||
const frames = new Map();
|
||||
let next = 0;
|
||||
let fetches = 0;
|
||||
let paints = 0;
|
||||
env.viewer.scene.screenSpaceCameraController.enableInputs = true;
|
||||
window.setTimeout = (callback) => {
|
||||
frames.set(++next, callback);
|
||||
return next;
|
||||
};
|
||||
window.clearTimeout = (id) => frames.delete(id);
|
||||
const owner = new CockpitViewController(env.viewer, {
|
||||
services: {
|
||||
regionalDistanceM: () => Infinity,
|
||||
fetchRegionalBrief: () => {
|
||||
fetches++;
|
||||
return Promise.resolve({ articles: [] });
|
||||
},
|
||||
releaseContinuousRender() {},
|
||||
},
|
||||
});
|
||||
owner.active = true;
|
||||
owner.showBriefPage = () => {
|
||||
paints++;
|
||||
};
|
||||
owner.setBriefAutoRotate(true);
|
||||
assert.equal(frames.size, 1);
|
||||
const queued = [...frames.values()][0];
|
||||
owner.stop();
|
||||
assert.equal(frames.size, 0);
|
||||
queued();
|
||||
owner.setBriefAutoRotate(true);
|
||||
owner.startBriefRotation();
|
||||
owner.maybeRefreshRegionalBrief({ latitude: 30, longitude: -97 });
|
||||
assert.equal(frames.size, 0);
|
||||
assert.equal(paints, 0);
|
||||
assert.equal(fetches, 0);
|
||||
owner.dispose();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the weather toggle flips from its own state, however fast it is pressed', () => {
|
||||
const env = environment();
|
||||
try {
|
||||
const toggle = new EventTarget();
|
||||
document.getElementById = (id) =>
|
||||
id === 'cockpit-weather-toggle' ? toggle : null;
|
||||
const requested = [];
|
||||
window.addEventListener('gev:cockpit-weather-toggle', (event) =>
|
||||
requested.push(event.detail.enabled),
|
||||
);
|
||||
const published = [];
|
||||
const owner = new CockpitViewController(env.viewer, {
|
||||
services: {},
|
||||
publish: { context: (view) => published.push(view.weather) },
|
||||
});
|
||||
// Two presses before any view renders still alternate.
|
||||
toggle.dispatchEvent(new Event('click'));
|
||||
toggle.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(requested, [true, false]);
|
||||
assert.deepEqual(published, [true, false]);
|
||||
// The effects owner reports its state; the next press flips that.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-weather-state', {
|
||||
detail: { enabled: true },
|
||||
}),
|
||||
);
|
||||
toggle.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(requested, [true, false, false]);
|
||||
assert.deepEqual(published, [true, false, true, false]);
|
||||
owner.dispose();
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import { nextTick } from 'vue';
|
||||
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
|
||||
import { cockpitEntryAllowed } from '../contextModePolicy.js';
|
||||
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
|
||||
import { enterCockpitWithTracking } from '../cockpitTracking.js';
|
||||
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
|
||||
|
||||
/** Own Cockpit entry/rollback, readouts and the single Display portal. */
|
||||
export class CockpitCoordinator {
|
||||
constructor({
|
||||
viewer,
|
||||
services,
|
||||
elements,
|
||||
operations,
|
||||
readDataManager,
|
||||
readContext,
|
||||
readActiveStyle,
|
||||
readUtilityDisclosureOpen,
|
||||
enterPanels,
|
||||
exitPanels,
|
||||
}) {
|
||||
Object.assign(
|
||||
this,
|
||||
{
|
||||
viewer,
|
||||
services,
|
||||
readDataManager,
|
||||
readContext,
|
||||
readActiveStyle,
|
||||
readUtilityDisclosureOpen,
|
||||
enterPanels,
|
||||
exitPanels,
|
||||
},
|
||||
elements,
|
||||
operations,
|
||||
);
|
||||
const {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
isTr3b,
|
||||
toggleTr3b,
|
||||
militaryAwarenessLayer,
|
||||
cachedGroundFloor,
|
||||
cachedMeshFloor,
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
meshFloorPreferred,
|
||||
warmGroundFloor,
|
||||
sampleMeshFloorCells,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
fetchRegionalBrief,
|
||||
regionalDistanceM,
|
||||
weatherCodeLabel,
|
||||
} = services;
|
||||
this._cockpitDisplayPortal = null;
|
||||
this.cockpitView = new CockpitViewController(viewer, {
|
||||
services: {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
isTr3b,
|
||||
toggleTr3b,
|
||||
militaryAwarenessLayer,
|
||||
formatAwarenessLabel,
|
||||
cachedGroundFloor,
|
||||
cachedMeshFloor,
|
||||
GROUND_FLOOR_LIFT_M,
|
||||
meshFloorPreferred,
|
||||
warmGroundFloor,
|
||||
sampleMeshFloorCells,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
fetchRegionalBrief,
|
||||
regionalDistanceM,
|
||||
weatherCodeLabel,
|
||||
},
|
||||
onVisionChange: (mode, active, options) =>
|
||||
this._setCockpitVision(mode, active, options),
|
||||
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
|
||||
onCameraTakeover: () =>
|
||||
this._stampNavigation({ cancelPendingSelection: false }),
|
||||
isEntryAllowed: () =>
|
||||
cockpitEntryAllowed({
|
||||
contextMode: this._contextMode,
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||
}),
|
||||
publish: this.publishCockpit,
|
||||
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
|
||||
onEntered: () => this.enterPanels(),
|
||||
onExited: () => this.exitPanels(),
|
||||
restoreTrackingFrame: (entity) => {
|
||||
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
|
||||
':',
|
||||
);
|
||||
const trackedId = idParts.join(':');
|
||||
if (!trackedId) return false;
|
||||
if (layerId === 'flights')
|
||||
return flightsLayer.refocusTrackedById?.(trackedId) === true;
|
||||
if (layerId === 'military')
|
||||
return militaryFlightsLayer.refocusTrackedById?.(trackedId) === true;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
get _dataManager() {
|
||||
return this.readDataManager();
|
||||
}
|
||||
get _contextMode() {
|
||||
return this.readContext()?.mode ?? null;
|
||||
}
|
||||
get _contextModeChanging() {
|
||||
return !!this.readContext()?.changing;
|
||||
}
|
||||
get activeStyle() {
|
||||
return this.readActiveStyle();
|
||||
}
|
||||
getCockpitState() {
|
||||
const { militaryAwarenessLayer } = this.services;
|
||||
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
|
||||
const info = this.cockpitView?.readAircraftInfo?.();
|
||||
const active = Boolean(this.cockpitView?.active);
|
||||
const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.());
|
||||
// "Could Cockpit be ENTERED right now" — so it is false while already
|
||||
// inside, unconditionally. Cockpit takes the entity off
|
||||
// `viewer.trackedEntity` on entry and NEXT puts one back, which made this
|
||||
// flip true/false between calls while `active` stayed true; readers
|
||||
// (including the voice model) read that as a broken half-entered state.
|
||||
const entryAllowed =
|
||||
!active &&
|
||||
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
|
||||
return {
|
||||
active,
|
||||
entryAllowed,
|
||||
// Why entry is unavailable, so a refusal can be explained rather than
|
||||
// guessed at.
|
||||
entryBlockedReason:
|
||||
entryAllowed || active
|
||||
? null
|
||||
: !gateOpen
|
||||
? this._contextModeChanging
|
||||
? 'contacts-starting'
|
||||
: 'contacts-inactive'
|
||||
: 'no-tracked-aircraft',
|
||||
visionMode: this.cockpitView?.visionMode || null,
|
||||
subject: info
|
||||
? {
|
||||
id: info.icao24 || info.id || null,
|
||||
layerId: info.layerId || null,
|
||||
callsign: info.callsign || null,
|
||||
}
|
||||
: null,
|
||||
navigation: snapshot
|
||||
? {
|
||||
canPrevious: Boolean(snapshot.navigation?.canPrevious),
|
||||
canNext: Boolean(snapshot.navigation?.canNext),
|
||||
canFocus: Boolean(snapshot.navigation?.canFocus),
|
||||
}
|
||||
: null,
|
||||
awareness: snapshot
|
||||
? {
|
||||
radiusM: Number.isFinite(snapshot.radiusM)
|
||||
? snapshot.radiusM
|
||||
: null,
|
||||
subject: snapshot.subject
|
||||
? {
|
||||
id: snapshot.subject.id || null,
|
||||
layerId: snapshot.subject.layerId || null,
|
||||
}
|
||||
: null,
|
||||
cohorts: Array.isArray(snapshot.cohorts)
|
||||
? snapshot.cohorts.map((cohort) => ({
|
||||
id: cohort?.id || null,
|
||||
source: cohort?.source || null,
|
||||
count: Number.isFinite(cohort?.count) ? cohort.count : null,
|
||||
relationship: cohort?.relationship || null,
|
||||
reason: cohort?.reason || null,
|
||||
coverage: cohort?.coverage || null,
|
||||
}))
|
||||
: [],
|
||||
navigation: snapshot.navigation
|
||||
? {
|
||||
canPrevious: Boolean(snapshot.navigation.canPrevious),
|
||||
canNext: Boolean(snapshot.navigation.canNext),
|
||||
canFocus: Boolean(snapshot.navigation.canFocus),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
activeTracked: this.cockpitView?.active
|
||||
? Boolean(this.cockpitView?.trackedEntity)
|
||||
: false,
|
||||
activeMapView: !this.cockpitView?.active && entryAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
_retargetCockpitEntryLayer({
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
currentTarget,
|
||||
selectedTarget,
|
||||
}) {
|
||||
const { militaryAwarenessLayer } = this.services;
|
||||
if (!['flights', 'military'].includes(targetLayer)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Cockpit flies aircraft only — ${targetLayer} contacts cannot be entered`,
|
||||
};
|
||||
}
|
||||
const activeLayer =
|
||||
selectedTarget?.layerId || currentTarget?.layerId || null;
|
||||
const alreadyOnLayer = activeLayer === targetLayer;
|
||||
if (alreadyOnLayer && !aircraftClass)
|
||||
return { ok: true, retargeted: false };
|
||||
const moved = militaryAwarenessLayer?.navigateNext
|
||||
? !!militaryAwarenessLayer.navigateNext({
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
origin: 'voice',
|
||||
})
|
||||
: false;
|
||||
if (moved) return { ok: true, retargeted: true };
|
||||
// A filter that matched nothing still enters, as long as the layer is
|
||||
// already right — the operator asked for that layer and is on it.
|
||||
if (alreadyOnLayer) return { ok: true, retargeted: false };
|
||||
const label = targetLayer === 'military' ? 'military' : 'civilian';
|
||||
const filtered = aircraftClass ? `${aircraftClass} ` : '';
|
||||
return {
|
||||
ok: false,
|
||||
error: `No ${filtered}${label} contact is available to enter — track one first, or say "next ${label}"`,
|
||||
};
|
||||
}
|
||||
|
||||
controlCockpit(
|
||||
action,
|
||||
{
|
||||
notificationToken = null,
|
||||
targetLayer = null,
|
||||
aircraftClass = null,
|
||||
selectedTarget = null,
|
||||
rollbackTarget = undefined,
|
||||
} = {},
|
||||
) {
|
||||
const { flightsLayer, militaryFlightsLayer } = this.services;
|
||||
const normalized = String(action || '').toLowerCase();
|
||||
if (!this.cockpitView) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'control_cockpit',
|
||||
error: 'Cockpit controller unavailable',
|
||||
state: this.getCockpitState(),
|
||||
};
|
||||
}
|
||||
if (normalized === 'status') {
|
||||
return {
|
||||
ok: true,
|
||||
action: 'control_cockpit',
|
||||
state: this.getCockpitState(),
|
||||
notificationToken: notificationToken || null,
|
||||
};
|
||||
}
|
||||
if (normalized === 'enter') {
|
||||
// Entry is gated exactly as the manual entry chip is. Attempting it while
|
||||
// the gate is shut produced the half-entered look the operator reported
|
||||
// (a plane anchored under the camera with no cockpit around it), so
|
||||
// refuse with the reason instead of trying.
|
||||
if (!this.cockpitView.isEntryAllowed?.()) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'control_cockpit',
|
||||
error: this._contextModeChanging
|
||||
? 'Contacts is still starting up — try Cockpit again in a moment'
|
||||
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
|
||||
state: this.getCockpitState(),
|
||||
};
|
||||
}
|
||||
let currentTarget = this.getAircraftTrackingTarget();
|
||||
const layerForTarget = (target) =>
|
||||
target?.layerId === 'military'
|
||||
? militaryFlightsLayer
|
||||
: target?.layerId === 'flights'
|
||||
? flightsLayer
|
||||
: null;
|
||||
// A requested layer retargets BEFORE entry, through the same filtered
|
||||
// navigation NEXT uses. Ignoring it entered on whatever was already
|
||||
// tracked and reported success, so "cockpit in that military helicopter"
|
||||
// silently put the operator in an airliner.
|
||||
if (targetLayer) {
|
||||
const requested = this._retargetCockpitEntryLayer({
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
currentTarget,
|
||||
selectedTarget,
|
||||
});
|
||||
if (!requested.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'control_cockpit',
|
||||
error: requested.error,
|
||||
state: this.getCockpitState(),
|
||||
};
|
||||
}
|
||||
if (requested.retargeted) {
|
||||
// The retarget is now the authority; a selection sampled before it
|
||||
// would drag entry back to the wrong layer.
|
||||
selectedTarget = null;
|
||||
rollbackTarget =
|
||||
rollbackTarget === undefined ? currentTarget : rollbackTarget;
|
||||
currentTarget = this.getAircraftTrackingTarget();
|
||||
}
|
||||
}
|
||||
const selectedLayer =
|
||||
selectedTarget?.layerId === 'military'
|
||||
? militaryFlightsLayer
|
||||
: selectedTarget?.layerId === 'flights'
|
||||
? flightsLayer
|
||||
: null;
|
||||
const entry = enterCockpitWithTracking({
|
||||
cockpitView: this.cockpitView,
|
||||
selectedLayer,
|
||||
selectedTarget,
|
||||
currentLayer: layerForTarget(currentTarget),
|
||||
rollbackLayer: layerForTarget(
|
||||
rollbackTarget === undefined ? currentTarget : rollbackTarget,
|
||||
),
|
||||
rollbackTarget,
|
||||
selectionOrigin: 'voice',
|
||||
});
|
||||
return {
|
||||
ok: entry.entered,
|
||||
action: 'control_cockpit',
|
||||
state: this.getCockpitState(),
|
||||
error: entry.error,
|
||||
};
|
||||
}
|
||||
if (normalized === 'exit') {
|
||||
const exited = !!this.cockpitView.exit();
|
||||
return {
|
||||
ok: exited,
|
||||
action: 'control_cockpit',
|
||||
state: this.getCockpitState(),
|
||||
error: exited ? null : 'Cockpit was already inactive',
|
||||
};
|
||||
}
|
||||
if (normalized === 'next' || normalized === 'previous') {
|
||||
const changed = this.cockpitView.navigateContext(
|
||||
normalized === 'next' ? 1 : -1,
|
||||
{
|
||||
targetLayer,
|
||||
aircraftClass,
|
||||
origin: 'voice',
|
||||
},
|
||||
);
|
||||
return {
|
||||
ok: changed,
|
||||
action: 'control_cockpit',
|
||||
state: this.getCockpitState(),
|
||||
error: changed ? null : 'No further context target was available',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
action: 'control_cockpit',
|
||||
error: `Unknown cockpit action: ${action}`,
|
||||
state: this.getCockpitState(),
|
||||
};
|
||||
}
|
||||
|
||||
_initCockpitDisplayPortal() {
|
||||
this._cockpitDisplayPortal?.destroy();
|
||||
this._cockpitDisplayPortal = new CockpitDisplayPortal({
|
||||
standardPanel: this._ppToggles,
|
||||
cockpitPanel: this._cockpitDisplayPanel,
|
||||
groups: [
|
||||
['hud', this._hudBtn?.closest('.pp-toggle-group')],
|
||||
['detection', this._detectionBtn?.closest('.pp-toggle-group')],
|
||||
['parameters', this._sliderPanel],
|
||||
['models3d', this._models3dBtn?.closest('.pp-toggle-group')],
|
||||
],
|
||||
layout: () => {
|
||||
this._layoutRightPanels();
|
||||
this.cockpitView?.scheduleContextLayout();
|
||||
},
|
||||
publish: (slots) => this.publishCockpitPortal?.(slots),
|
||||
afterRender: (callback) => {
|
||||
void nextTick(callback);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_setCockpitDisplayPortalActive(active) {
|
||||
this._cockpitDisplayPortal?.setActive(active);
|
||||
}
|
||||
|
||||
get _cockpitDisplayPortalActive() {
|
||||
return this._cockpitDisplayPortal?.active ?? false;
|
||||
}
|
||||
|
||||
get _displayPortalScrollRestoreOwner() {
|
||||
return this._cockpitDisplayPortal?.restoreOwner ?? null;
|
||||
}
|
||||
|
||||
get _standardDisplayScrollTop() {
|
||||
return this._cockpitDisplayPortal?.standardScrollTop ?? 0;
|
||||
}
|
||||
stop() {
|
||||
this.cockpitView?.stop();
|
||||
this._cockpitDisplayPortal?.stop();
|
||||
}
|
||||
destroy() {
|
||||
this.cockpitView?.dispose();
|
||||
this._cockpitDisplayPortal?.destroy();
|
||||
this._cockpitDisplayPortal = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
|
||||
/**
|
||||
* Show the existing Display groups in Cockpit's Display popover while Cockpit
|
||||
* is on. The portal publishes the popover's slots; the Display panel
|
||||
* teleports its groups into them and back. The portal keeps each surface's
|
||||
* scroll position and puts back focus the move dropped.
|
||||
*/
|
||||
export class CockpitDisplayPortal {
|
||||
constructor({
|
||||
standardPanel,
|
||||
cockpitPanel,
|
||||
groups,
|
||||
layout,
|
||||
publish = () => {},
|
||||
afterRender = (callback) => callback(),
|
||||
}) {
|
||||
this.standardPanel = standardPanel;
|
||||
this.cockpitPanel = cockpitPanel;
|
||||
this.layout = layout;
|
||||
this.publish = publish;
|
||||
this.afterRender = afterRender;
|
||||
this.active = false;
|
||||
this.destroyed = false;
|
||||
this.stopped = false;
|
||||
this.restoreOwner = null;
|
||||
this.generation = 0;
|
||||
this.frames = new Set();
|
||||
this.listeners = new AbortController();
|
||||
this.records = groups.flatMap(([name, group]) => {
|
||||
const slot = cockpitPanel?.querySelector(
|
||||
`[data-cockpit-display-slot="${name}"]`,
|
||||
);
|
||||
if (!group || !slot) return [];
|
||||
return [{ name, group, slot }];
|
||||
});
|
||||
this.slots = Object.freeze(
|
||||
Object.fromEntries(this.records.map(({ name, slot }) => [name, slot])),
|
||||
);
|
||||
this.standardScrollTop =
|
||||
displayPanelScroller(standardPanel)?.scrollTop || 0;
|
||||
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
|
||||
const options = { passive: true, signal: this.listeners.signal };
|
||||
standardPanel?.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (!this.active)
|
||||
this.standardScrollTop =
|
||||
displayPanelScroller(standardPanel).scrollTop;
|
||||
},
|
||||
{ ...options, capture: true },
|
||||
);
|
||||
cockpitPanel?.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (this.active) this.cockpitScrollTop = cockpitPanel.scrollTop;
|
||||
},
|
||||
options,
|
||||
);
|
||||
window.addEventListener(
|
||||
'gev:cockpit-mode-changed',
|
||||
(event) => {
|
||||
this.setActive(event?.detail?.active === true);
|
||||
},
|
||||
{ signal: this.listeners.signal },
|
||||
);
|
||||
this.setActive(document.body.classList.contains('cockpit-mode'));
|
||||
}
|
||||
|
||||
_frame(generation, callback) {
|
||||
const frame = requestAnimationFrame(() => {
|
||||
this.frames.delete(frame);
|
||||
if (!this.stopped && generation === this.generation) callback();
|
||||
});
|
||||
this.frames.add(frame);
|
||||
}
|
||||
|
||||
_cancelFrames() {
|
||||
for (const frame of this.frames) cancelAnimationFrame(frame);
|
||||
this.frames.clear();
|
||||
this.generation += 1;
|
||||
}
|
||||
|
||||
setActive(active, { settle = true } = {}) {
|
||||
if (this.stopped) return;
|
||||
const nextActive = active === true;
|
||||
if (this.active === nextActive) return;
|
||||
this._cancelFrames();
|
||||
const generation = this.generation;
|
||||
const focusedRecord = this.records.find((record) =>
|
||||
record.group.contains(document.activeElement),
|
||||
);
|
||||
const focusedElement = focusedRecord ? document.activeElement : null;
|
||||
this.restoreOwner = nextActive ? 'cockpit' : 'standard';
|
||||
this.active = nextActive;
|
||||
this.publish(nextActive ? this.slots : null);
|
||||
const restoreScroll = () => {
|
||||
if (nextActive && this.cockpitPanel)
|
||||
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
|
||||
if (!nextActive && this.standardPanel)
|
||||
displayPanelScroller(this.standardPanel).scrollTop =
|
||||
this.standardScrollTop;
|
||||
};
|
||||
if (!settle) {
|
||||
this.afterRender(() => {
|
||||
if (generation !== this.generation) return;
|
||||
restoreScroll();
|
||||
this.restoreOwner = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
this._frame(generation, () => {
|
||||
restoreScroll();
|
||||
// Restore only the focus the move dropped: Cockpit entry hands focus to
|
||||
// its exit button in the meantime, and that wins.
|
||||
const current = document.activeElement;
|
||||
if (
|
||||
!current ||
|
||||
current === document.body ||
|
||||
focusedRecord?.group.contains(current)
|
||||
)
|
||||
focusedElement?.focus?.({ preventScroll: true });
|
||||
this._frame(generation, () => {
|
||||
restoreScroll();
|
||||
this.restoreOwner = null;
|
||||
});
|
||||
});
|
||||
// The surfaces are measured once the groups have moved.
|
||||
this.afterRender(() => {
|
||||
if (!this.stopped && generation === this.generation) this.layout();
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.stopped) return;
|
||||
this.stopped = true;
|
||||
this._cancelFrames();
|
||||
this.listeners.abort();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.stop();
|
||||
this.destroyed = true;
|
||||
this.active = false;
|
||||
this.publish(null);
|
||||
const { standardPanel, standardScrollTop } = this;
|
||||
this.afterRender(() => {
|
||||
if (standardPanel)
|
||||
displayPanelScroller(standardPanel).scrollTop = standardScrollTop;
|
||||
});
|
||||
this.records = [];
|
||||
this.restoreOwner = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
|
||||
function fixture() {
|
||||
const prior = Object.fromEntries(
|
||||
['document', 'window', 'requestAnimationFrame', 'cancelAnimationFrame'].map(
|
||||
(key) => [key, globalThis[key]],
|
||||
),
|
||||
);
|
||||
const frames = new Map();
|
||||
let nextFrame = 0;
|
||||
let focusCalls = 0;
|
||||
let layouts = 0;
|
||||
class Node extends EventTarget {
|
||||
constructor(name) {
|
||||
super();
|
||||
this.name = name;
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.scrollTop = 0;
|
||||
this.classList = { toggle() {}, remove() {} };
|
||||
}
|
||||
append(node) {
|
||||
node.remove();
|
||||
this.children.push(node);
|
||||
node.parentNode = this;
|
||||
}
|
||||
remove() {
|
||||
if (this.parentNode)
|
||||
this.parentNode.children.splice(
|
||||
this.parentNode.children.indexOf(this),
|
||||
1,
|
||||
);
|
||||
this.parentNode = null;
|
||||
}
|
||||
before(node) {
|
||||
node.remove();
|
||||
const parent = this.parentNode;
|
||||
parent.children.splice(parent.children.indexOf(this), 0, node);
|
||||
node.parentNode = parent;
|
||||
}
|
||||
after(node) {
|
||||
node.remove();
|
||||
const parent = this.parentNode;
|
||||
parent.children.splice(parent.children.indexOf(this) + 1, 0, node);
|
||||
node.parentNode = parent;
|
||||
}
|
||||
contains(node) {
|
||||
return (
|
||||
this === node || this.children.some((child) => child.contains(node))
|
||||
);
|
||||
}
|
||||
focus() {
|
||||
focusCalls += 1;
|
||||
document.activeElement = this;
|
||||
}
|
||||
}
|
||||
const standard = new Node('standard');
|
||||
standard.scrollTop = 71;
|
||||
const cockpit = new Node('cockpit');
|
||||
cockpit.scrollTop = 19;
|
||||
const group = new Node('group');
|
||||
const sibling = new Node('sibling');
|
||||
const slot = new Node('slot');
|
||||
standard.append(group);
|
||||
standard.append(sibling);
|
||||
cockpit.append(slot);
|
||||
cockpit.querySelector = () => slot;
|
||||
globalThis.document = {
|
||||
createComment: (name) => new Node(name),
|
||||
activeElement: group,
|
||||
body: { classList: { contains: () => false } },
|
||||
};
|
||||
globalThis.window = new EventTarget();
|
||||
globalThis.requestAnimationFrame = (fn) => {
|
||||
const id = ++nextFrame;
|
||||
frames.set(id, fn);
|
||||
return id;
|
||||
};
|
||||
globalThis.cancelAnimationFrame = (id) => frames.delete(id);
|
||||
const published = [];
|
||||
// The Display panel's Teleport: the group moves to the published slot and
|
||||
// back to its home ahead of its sibling.
|
||||
const publish = (slots) => {
|
||||
published.push(slots);
|
||||
if (slots) slots.hud.append(group);
|
||||
else if (group.parentNode !== standard) standard.children[0].before(group);
|
||||
};
|
||||
const create = () =>
|
||||
new CockpitDisplayPortal({
|
||||
standardPanel: standard,
|
||||
cockpitPanel: cockpit,
|
||||
groups: [['hud', group]],
|
||||
layout: () => {
|
||||
layouts += 1;
|
||||
},
|
||||
publish,
|
||||
});
|
||||
return {
|
||||
standard,
|
||||
cockpit,
|
||||
group,
|
||||
sibling,
|
||||
slot,
|
||||
frames,
|
||||
create,
|
||||
published,
|
||||
get focusCalls() {
|
||||
return focusCalls;
|
||||
},
|
||||
get layouts() {
|
||||
return layouts;
|
||||
},
|
||||
flush() {
|
||||
while (frames.size) {
|
||||
const batch = [...frames.values()];
|
||||
frames.clear();
|
||||
batch.forEach((fn) => fn());
|
||||
}
|
||||
},
|
||||
restore() {
|
||||
Object.assign(globalThis, prior);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Display groups return to their exact home with independent scroll positions', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const owner = f.create();
|
||||
owner.setActive(true);
|
||||
assert.deepEqual(f.published, [{ hud: f.slot }]);
|
||||
assert.equal(f.group.parentNode, f.slot);
|
||||
f.cockpit.scrollTop = 0;
|
||||
f.flush();
|
||||
assert.equal(f.cockpit.scrollTop, 19);
|
||||
assert.equal(f.focusCalls, 1);
|
||||
owner.setActive(false);
|
||||
f.standard.scrollTop = 0;
|
||||
f.flush();
|
||||
assert.equal(f.standard.scrollTop, 71);
|
||||
owner.destroy();
|
||||
assert.deepEqual(f.published, [{ hud: f.slot }, null, null]);
|
||||
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
|
||||
assert.equal(f.frames.size, 0);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the surfaces are laid out and scrolled once the groups have rendered', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const rendered = [];
|
||||
const owner = new CockpitDisplayPortal({
|
||||
standardPanel: f.standard,
|
||||
cockpitPanel: f.cockpit,
|
||||
groups: [['hud', f.group]],
|
||||
layout: () => rendered.push('layout'),
|
||||
publish: (slots) => rendered.push(slots ? 'cockpit' : 'standard'),
|
||||
afterRender: (callback) => rendered.push(callback),
|
||||
});
|
||||
owner.setActive(true, { settle: false });
|
||||
// Published first; the scroll waits for the render.
|
||||
assert.deepEqual(rendered.slice(0, 1), ['cockpit']);
|
||||
f.cockpit.scrollTop = 0;
|
||||
rendered.splice(1).forEach((callback) => callback());
|
||||
assert.equal(f.cockpit.scrollTop, 19);
|
||||
owner.setActive(false);
|
||||
const pending = rendered.filter((item) => typeof item === 'function');
|
||||
assert.equal(rendered.includes('layout'), false);
|
||||
pending.forEach((callback) => callback());
|
||||
assert.equal(rendered.at(-1), 'layout');
|
||||
owner.destroy();
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('focus moved elsewhere before the frame is not taken back', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const owner = f.create();
|
||||
owner.setActive(true);
|
||||
// Cockpit entry focuses its exit button before the portal's frame runs.
|
||||
document.activeElement = f.sibling;
|
||||
f.flush();
|
||||
assert.equal(f.focusCalls, 0);
|
||||
assert.equal(document.activeElement === f.sibling, true);
|
||||
owner.destroy();
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('superseded portal frames cannot restore obsolete focus or scroll', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const owner = f.create();
|
||||
owner.setActive(true);
|
||||
const obsolete = [...f.frames.values()];
|
||||
owner.setActive(false);
|
||||
f.cockpit.scrollTop = 93;
|
||||
obsolete.forEach((callback) => callback());
|
||||
assert.equal(f.focusCalls, 0);
|
||||
assert.equal(f.cockpit.scrollTop, 93);
|
||||
f.flush();
|
||||
assert.equal(f.focusCalls, 1);
|
||||
assert.equal(owner.restoreOwner, null);
|
||||
owner.destroy();
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('disposal restores groups and revokes mode listeners and queued focus work', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const owner = f.create();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
|
||||
);
|
||||
assert.equal(f.group.parentNode, f.slot);
|
||||
const obsolete = [...f.frames.values()];
|
||||
owner.destroy();
|
||||
owner.destroy();
|
||||
obsolete.forEach((callback) => callback());
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-mode-changed', { detail: { active: true } }),
|
||||
);
|
||||
owner.setActive(true);
|
||||
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
|
||||
assert.equal(f.frames.size, 0);
|
||||
assert.equal(f.focusCalls, 0);
|
||||
assert.equal(f.layouts, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a stopped portal waits for cleanup without running queued focus work', () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const owner = f.create();
|
||||
owner.setActive(true);
|
||||
const queued = [...f.frames.values()];
|
||||
owner.stop();
|
||||
queued.forEach((callback) => callback());
|
||||
owner.setActive(false);
|
||||
assert.equal(f.group.parentNode, f.slot);
|
||||
assert.equal(f.frames.size, 0);
|
||||
assert.equal(f.focusCalls, 0);
|
||||
owner.destroy();
|
||||
assert.deepEqual(f.standard.children, [f.group, f.sibling]);
|
||||
} finally {
|
||||
f.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
|
||||
|
||||
export function onKeyDown(event) {
|
||||
if (this.destroyed) return false;
|
||||
if (event.repeat || event.isComposing) return;
|
||||
if (event.key === 'Escape' && this.active) {
|
||||
// The credit lightbox owns Escape while its Close control or links hold
|
||||
// focus. Its target handler closes the overlay and restores attribution
|
||||
// focus; Cockpit must stay active behind it.
|
||||
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
|
||||
// An open Display or Radio popover takes Escape itself.
|
||||
if (this.isUtilityDisclosureOpen?.()) return;
|
||||
if (this.context?.contains(event.target) && !this.contextCollapsed) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.setContextCollapsed(true);
|
||||
if (
|
||||
event.target === this.contextToggle ||
|
||||
this.contextToggle?.contains?.(event.target)
|
||||
) {
|
||||
this.contextToggle?.blur?.();
|
||||
} else {
|
||||
this.contextToggle?.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.setSignalCollapsed(true, { user: true });
|
||||
if (
|
||||
event.target === this.signalToggle ||
|
||||
this.signalToggle?.contains?.(event.target)
|
||||
) {
|
||||
this.signalToggle?.blur?.();
|
||||
} else {
|
||||
this.signalToggle?.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
this.exit();
|
||||
return;
|
||||
}
|
||||
if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
|
||||
return;
|
||||
const key = event.key?.toLowerCase();
|
||||
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
if (!this.active) {
|
||||
const cockpitAttempt = !!(
|
||||
this.readAircraftInfo() && this.viewer.trackedEntity?.position
|
||||
);
|
||||
if (!cockpitAttempt) return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!this.active && !this.isEntryAllowed()) return;
|
||||
if (this.active) this.exit();
|
||||
else this.enter();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The Cockpit instruments, route and vision selector as they first render.
|
||||
* It needs only the i18n module, so the controller and the views can both
|
||||
* read it.
|
||||
*/
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/** Ticks on each altitude and speed rim. */
|
||||
const COCKPIT_RIM_TICKS = 9;
|
||||
|
||||
/** The vision selector's short label for each mode. */
|
||||
const COCKPIT_VISION_LABELS = Object.freeze({
|
||||
optical: msg('NORMAL'),
|
||||
crt: msg('CRT'),
|
||||
nvg: msg('NVG'),
|
||||
thermal: msg('FLIR'),
|
||||
anime: msg('ANIME'),
|
||||
noir: msg('NOIR'),
|
||||
});
|
||||
|
||||
/** The name each mode is announced and titled with. */
|
||||
const COCKPIT_VISION_NAMES = Object.freeze({
|
||||
optical: msg('Normal'),
|
||||
crt: msg('CRT'),
|
||||
nvg: msg('Night vision'),
|
||||
thermal: msg('Thermal'),
|
||||
anime: msg('Anime'),
|
||||
noir: msg('Noir'),
|
||||
});
|
||||
|
||||
/** A rim before any reading: its ticks shown but unplaced and unlabelled. */
|
||||
function createRimState(value) {
|
||||
return {
|
||||
unavailable: false,
|
||||
value,
|
||||
ticks: Array.from({ length: COCKPIT_RIM_TICKS }, () => ({
|
||||
hidden: false,
|
||||
slot: null,
|
||||
depth: null,
|
||||
curve: null,
|
||||
major: false,
|
||||
label: '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The instruments before any aircraft reading: readouts show their
|
||||
* placeholders. The controller keeps it, changes it in place on every HUD
|
||||
* update and publishes a copy; `CockpitView` renders it.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createInstrumentState() {
|
||||
return {
|
||||
layer: null,
|
||||
callsign: t('AIRCRAFT'),
|
||||
meta: { text: t('LIVE TRACK · COURSE ALIGNED'), emergency: false },
|
||||
speed: null,
|
||||
altitude: null,
|
||||
heading: null,
|
||||
speedRim: createRimState('---'),
|
||||
altitudeRim: createRimState('-----'),
|
||||
compass: [],
|
||||
clock: '--:--:--Z',
|
||||
position: '--° · --°',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The flight plan card and the visor's destination arrow before any route:
|
||||
* both hidden.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRouteState() {
|
||||
return {
|
||||
visible: false,
|
||||
from: t('UNKNOWN'),
|
||||
to: t('UNKNOWN'),
|
||||
status: t('ROUTE DATA UNAVAILABLE'),
|
||||
direction: { visible: false, angle: null, label: t('DEST ---°') },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The vision selector showing `mode`.
|
||||
* @param {string} mode A normalized Cockpit vision mode.
|
||||
* @returns {{mode: string, label: string, name: string}}
|
||||
*/
|
||||
export function createVisionState(mode = 'optical') {
|
||||
return {
|
||||
mode,
|
||||
label: t(COCKPIT_VISION_LABELS[mode]),
|
||||
name: t(COCKPIT_VISION_NAMES[mode]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/** Instrument values, routes and vision controls for the Cockpit controller. */
|
||||
import { formatNumber, t } from '../i18n/index.js';
|
||||
import {
|
||||
COCKPIT_VISION_MODES,
|
||||
normalizeCockpitVisionMode,
|
||||
} from '../cockpitVisionPolicy.js';
|
||||
import {
|
||||
altitudeRulerCurveInset,
|
||||
altitudeRulerTicks,
|
||||
bearingBetweenCoordinates,
|
||||
cockpitAltitudeDisplayFt,
|
||||
cockpitUiUpdateDue,
|
||||
compassDivisions,
|
||||
formatAltitudeRulerTick,
|
||||
formatCompassDivision,
|
||||
formatCockpitEmergency,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
speedRulerTicks,
|
||||
} from '../cockpitMath.js';
|
||||
import {
|
||||
COCKPIT_CONTEXT_UPDATE_MS,
|
||||
compassPointLabel,
|
||||
formatCockpitCoordinate,
|
||||
} from './cockpitPresentation.js';
|
||||
import { createVisionState } from './cockpitInstrumentState.js';
|
||||
|
||||
/** A transponder status name (src/data/aircraftEmergency.js) for the HUD. */
|
||||
const emergencyLabel = ({ name }) => t(name).toUpperCase();
|
||||
|
||||
/** The emergency line in the active language (see cockpitMath.js). */
|
||||
const LOCALIZED_EMERGENCY = Object.freeze({
|
||||
squawk: (parts) =>
|
||||
t('SQUAWK {code} · {status}', {
|
||||
code: parts.squawk,
|
||||
status: emergencyLabel(parts),
|
||||
}),
|
||||
adsb: (parts) =>
|
||||
parts.priority
|
||||
? t('ADS-B PRIORITY · {status}', { status: emergencyLabel(parts) })
|
||||
: t('ADS-B EMERGENCY · {status}', { status: emergencyLabel(parts) }),
|
||||
});
|
||||
|
||||
/** Place a rim's ticks; a tick with no reading hides and keeps its place. */
|
||||
function placeRimTicks(ticks, readings, label) {
|
||||
ticks.forEach((tick, index) => {
|
||||
const reading = readings[index];
|
||||
tick.hidden = !reading;
|
||||
if (!reading) return;
|
||||
tick.slot = reading.slot.toFixed(4);
|
||||
tick.depth = reading.depth.toFixed(4);
|
||||
tick.curve = altitudeRulerCurveInset(reading.slot).toFixed(5);
|
||||
tick.major = reading.major;
|
||||
tick.label = label(reading);
|
||||
});
|
||||
}
|
||||
|
||||
export function publishInstrumentsView() {
|
||||
const view = this.instruments;
|
||||
const rim = (state) => ({
|
||||
...state,
|
||||
ticks: state.ticks.map((tick) => ({ ...tick })),
|
||||
});
|
||||
this.publishInstruments({
|
||||
...view,
|
||||
meta: { ...view.meta },
|
||||
speed: view.speed && { ...view.speed },
|
||||
altitude: view.altitude && { ...view.altitude },
|
||||
heading: view.heading && { ...view.heading },
|
||||
speedRim: rim(view.speedRim),
|
||||
altitudeRim: rim(view.altitudeRim),
|
||||
compass: view.compass.map((division) => ({ ...division })),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateHud(
|
||||
info,
|
||||
nowMs = performance.now(),
|
||||
forceContext = false,
|
||||
) {
|
||||
this.lastAircraftInfo = info;
|
||||
const view = this.instruments;
|
||||
const heading = normalizeHeading(this.heading ?? info.track ?? 0);
|
||||
view.callsign =
|
||||
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
|
||||
const speedKt = Number.isFinite(info.velocityMps)
|
||||
? info.velocityMps * 1.94384
|
||||
: null;
|
||||
view.speed = {
|
||||
text: formatSpeedRulerTick(speedKt),
|
||||
value: speedKt,
|
||||
immediate: forceContext,
|
||||
};
|
||||
view.speedRim.unavailable = speedKt === null;
|
||||
view.speedRim.value = formatSpeedRulerTick(speedKt);
|
||||
placeRimTicks(
|
||||
view.speedRim.ticks,
|
||||
speedRulerTicks(speedKt, view.speedRim.ticks.length),
|
||||
(tick) => formatSpeedRulerTick(tick.valueKt),
|
||||
);
|
||||
const altitudeFt = cockpitAltitudeDisplayFt(info.altitudeM, info.onGround);
|
||||
const displayedAltitudeFt = Number.isFinite(altitudeFt)
|
||||
? Math.round(altitudeFt)
|
||||
: null;
|
||||
view.altitude = {
|
||||
text:
|
||||
displayedAltitudeFt !== null
|
||||
? formatNumber(displayedAltitudeFt)
|
||||
: '-----',
|
||||
value: displayedAltitudeFt,
|
||||
immediate: forceContext,
|
||||
};
|
||||
view.altitudeRim.unavailable = altitudeFt === null;
|
||||
view.altitudeRim.value = formatAltitudeRulerTick(altitudeFt);
|
||||
placeRimTicks(
|
||||
view.altitudeRim.ticks,
|
||||
altitudeRulerTicks(altitudeFt, view.altitudeRim.ticks.length),
|
||||
(tick) => formatAltitudeRulerTick(tick.valueFt),
|
||||
);
|
||||
view.heading = {
|
||||
text: formatHeadingReadout(heading),
|
||||
value: heading,
|
||||
immediate: forceContext,
|
||||
};
|
||||
view.compass = compassDivisions(heading).map((division, index) => ({
|
||||
slot: index - 3,
|
||||
label: compassPointLabel(formatCompassDivision(division)),
|
||||
}));
|
||||
view.clock = new Date().toISOString().slice(11, 19) + 'Z';
|
||||
const lat = Number.isFinite(info.latitude)
|
||||
? formatCockpitCoordinate(info.latitude, 'N', 'S')
|
||||
: '--';
|
||||
const lon = Number.isFinite(info.longitude)
|
||||
? formatCockpitCoordinate(info.longitude, 'E', 'W')
|
||||
: '--';
|
||||
view.position = `${lat} · ${lon}`;
|
||||
const feedState = this.surfaceAcquiring
|
||||
? t('ACQUIRING SURFACE')
|
||||
: this.surfaceFallback
|
||||
? t('SURFACE FALLBACK')
|
||||
: info.stale
|
||||
? t('STALE FEED')
|
||||
: t('LIVE TRACK');
|
||||
const aircraftClass =
|
||||
info.layerId === 'military' ? t('MILITARY') : t('COMMERCIAL');
|
||||
const emergency = formatCockpitEmergency(info.emergency, LOCALIZED_EMERGENCY);
|
||||
view.meta.text = emergency
|
||||
? t('{aircraftClass} · {emergency} · {feedState}', {
|
||||
aircraftClass,
|
||||
emergency,
|
||||
feedState,
|
||||
})
|
||||
: t('{aircraftClass} · {feedState} · COURSE ALIGNED', {
|
||||
aircraftClass,
|
||||
feedState,
|
||||
});
|
||||
view.meta.emergency = info.emergency?.severity === 'emergency';
|
||||
view.layer = info.layerId || 'flights';
|
||||
this.publishInstrumentsView();
|
||||
this.updateRoute(info);
|
||||
if (
|
||||
forceContext ||
|
||||
cockpitUiUpdateDue(
|
||||
nowMs,
|
||||
this.lastContextUpdateMs,
|
||||
COCKPIT_CONTEXT_UPDATE_MS,
|
||||
)
|
||||
) {
|
||||
this.lastContextUpdateMs = nowMs;
|
||||
this.updateLocalPosition(info);
|
||||
this.maybeRefreshRegionalBrief(info);
|
||||
this.updateContext(info, heading);
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the route card and destination arrow when either changed. */
|
||||
export function publishRouteView() {
|
||||
const signature = JSON.stringify(this.routeView);
|
||||
if (signature === this.routeSignature) return;
|
||||
this.routeSignature = signature;
|
||||
this.publishRoute({
|
||||
...this.routeView,
|
||||
direction: { ...this.routeView.direction },
|
||||
});
|
||||
}
|
||||
|
||||
export function updateRoute(info) {
|
||||
const route = this.routeView;
|
||||
const origin = info?.route?.origin;
|
||||
const destination = info?.route?.destination;
|
||||
const validDestination =
|
||||
Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon);
|
||||
const routeLabel = (airport) =>
|
||||
[airport?.code, airport?.name].filter(Boolean).join(' · ') || t('UNKNOWN');
|
||||
route.from = routeLabel(origin);
|
||||
route.to = routeLabel(destination);
|
||||
route.status = validDestination
|
||||
? t('ARROW · ESTIMATED DIRECTION')
|
||||
: t('ROUTE DATA UNAVAILABLE');
|
||||
route.visible = !!(origin || destination);
|
||||
if (
|
||||
!validDestination ||
|
||||
!Number.isFinite(info?.longitude) ||
|
||||
!Number.isFinite(info?.latitude)
|
||||
) {
|
||||
this.clearPredictiveRoute();
|
||||
return;
|
||||
}
|
||||
const destinationBearing = bearingBetweenCoordinates(
|
||||
info.latitude,
|
||||
info.longitude,
|
||||
destination.lat,
|
||||
destination.lon,
|
||||
);
|
||||
const relative = relativeBearing(
|
||||
destinationBearing,
|
||||
this.heading ?? info.track ?? 0,
|
||||
);
|
||||
if (!Number.isFinite(destinationBearing) || !Number.isFinite(relative)) {
|
||||
this.clearPredictiveRoute();
|
||||
return;
|
||||
}
|
||||
const displayedRelative = Math.max(-120, Math.min(120, relative));
|
||||
route.direction.visible = true;
|
||||
route.direction.angle = `${displayedRelative.toFixed(2)}deg`;
|
||||
route.direction.label = t('DEST {bearing}', {
|
||||
bearing: `${formatHeadingReadout(destinationBearing)}°`,
|
||||
});
|
||||
this.publishRouteView();
|
||||
}
|
||||
|
||||
export function syncWeatherToggle(enabled) {
|
||||
this.contextView.weather = !!enabled;
|
||||
this.publishContextView();
|
||||
}
|
||||
|
||||
export function setVisionMode(mode, { revealParameters = false } = {}) {
|
||||
const next = normalizeCockpitVisionMode(mode);
|
||||
this.visionMode = next;
|
||||
this.publishVision(createVisionState(next));
|
||||
this.onVisionChange?.(next, this.active, { revealParameters });
|
||||
}
|
||||
|
||||
export function cycleVisionMode(direction = 1) {
|
||||
const modes = COCKPIT_VISION_MODES;
|
||||
const currentIndex = Math.max(0, modes.indexOf(this.visionMode));
|
||||
const step = direction < 0 ? -1 : 1;
|
||||
const nextIndex = (currentIndex + step + modes.length) % modes.length;
|
||||
this.setVisionMode(modes[nextIndex], { revealParameters: true });
|
||||
}
|
||||
|
||||
export function clearPredictiveRoute() {
|
||||
this.routeView.direction.visible = false;
|
||||
this.publishRouteView();
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// src/ui/cockpitInstruments.test.mjs — the Cockpit HUD's instruments, flight
|
||||
// plan and vision selector render what the Cockpit controller presents, over
|
||||
// happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
createInstrumentState,
|
||||
createRouteState,
|
||||
} from './cockpitInstrumentState.js';
|
||||
import {
|
||||
clearPredictiveRoute,
|
||||
publishInstrumentsView,
|
||||
publishRouteView,
|
||||
setVisionMode,
|
||||
updateHud,
|
||||
updateRoute,
|
||||
} from './cockpitInstruments.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitView).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useCockpitStore(pinia);
|
||||
const published = { instruments: 0, route: 0 };
|
||||
// The instrument presenters need only this much of the controller.
|
||||
const owner = {
|
||||
heading: null,
|
||||
surfaceAcquiring: false,
|
||||
surfaceFallback: false,
|
||||
lastContextUpdateMs: 0,
|
||||
instruments: createInstrumentState(),
|
||||
routeView: createRouteState(),
|
||||
routeSignature: null,
|
||||
publishInstruments: (view) => {
|
||||
published.instruments += 1;
|
||||
store.publishInstruments(view);
|
||||
},
|
||||
publishRoute: (view) => {
|
||||
published.route += 1;
|
||||
store.publishRoute(view);
|
||||
},
|
||||
publishVision: (view) => store.publishVision(view),
|
||||
publishInstrumentsView,
|
||||
publishRouteView,
|
||||
updateRoute,
|
||||
clearPredictiveRoute,
|
||||
// The briefing and Contact card have their own tests.
|
||||
updateLocalPosition() {},
|
||||
maybeRefreshRegionalBrief() {},
|
||||
updateContext() {},
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const text = (selector) => $(selector).textContent.trim();
|
||||
const ticks = (kind) => [
|
||||
...document.querySelectorAll(`[data-${kind}-rim-tick]`),
|
||||
];
|
||||
return { $, text, ticks, owner, published, store };
|
||||
}
|
||||
|
||||
const INFO = Object.freeze({
|
||||
callsign: 'UAL123',
|
||||
velocityMps: 128.6,
|
||||
altitudeM: 3000,
|
||||
track: 92,
|
||||
latitude: 30.1975,
|
||||
longitude: -97.6664,
|
||||
layerId: 'flights',
|
||||
});
|
||||
|
||||
const ROUTED = Object.freeze({
|
||||
...INFO,
|
||||
route: {
|
||||
origin: { code: 'AUS', name: 'Austin' },
|
||||
destination: { code: 'DEN', name: 'Denver', lat: 39.86, lon: -104.67 },
|
||||
},
|
||||
});
|
||||
|
||||
test('before the controller presents, the HUD shows the state it starts in', (t) => {
|
||||
const { $, text, ticks } = fixture(t);
|
||||
assert.equal(text('#cockpit-callsign'), 'AIRCRAFT');
|
||||
assert.equal(text('#cockpit-aircraft-meta'), 'LIVE TRACK · COURSE ALIGNED');
|
||||
assert.equal(text('#cockpit-speed-value'), '---');
|
||||
assert.equal(text('#cockpit-altitude-value'), '-----');
|
||||
assert.equal(text('#cockpit-heading-value'), '---');
|
||||
assert.equal($('#cockpit-speed-value').hasAttribute('aria-label'), false);
|
||||
assert.equal(text('#cockpit-speed-rim-value'), '---');
|
||||
assert.equal(text('#cockpit-altitude-rim-value'), '-----');
|
||||
assert.equal(text('#cockpit-clock'), '--:--:--Z');
|
||||
assert.equal(text('#cockpit-position'), '--° · --°');
|
||||
assert.equal($('#cockpit-compass-tape').children.length, 0);
|
||||
assert.equal(ticks('speed').length, 9);
|
||||
assert.equal(ticks('altitude').length, 9);
|
||||
assert.equal(
|
||||
ticks('speed').every((tick) => !tick.hidden && !tick.getAttribute('style')),
|
||||
true,
|
||||
);
|
||||
assert.equal($('#cockpit-route').hidden, true);
|
||||
assert.equal($('#cockpit-route-direction').hidden, true);
|
||||
assert.equal(text('#cockpit-route-direction-label'), 'DEST ---°');
|
||||
assert.equal($('#cockpit-hud').hasAttribute('data-layer'), false);
|
||||
const vision = $('#cockpit-vision-current');
|
||||
assert.equal(vision.dataset.cockpitVision, 'optical');
|
||||
assert.equal(text('#cockpit-vision-current-label'), 'NORMAL');
|
||||
assert.equal(
|
||||
vision.getAttribute('aria-label'),
|
||||
'Current cockpit vision style: Normal. Activate for next style.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a HUD update renders every readout', async (t) => {
|
||||
const { $, text, ticks, owner } = fixture(t);
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-callsign'), 'UAL123');
|
||||
assert.equal(
|
||||
text('#cockpit-aircraft-meta'),
|
||||
'COMMERCIAL · LIVE TRACK · COURSE ALIGNED',
|
||||
);
|
||||
// 128.6 m/s is 250 kt; 3000 m is 9,843 ft; the track is the heading.
|
||||
const speed = $('#cockpit-speed-value');
|
||||
assert.equal(speed.tagName, 'STRONG');
|
||||
assert.equal(speed.getAttribute('aria-label'), '250');
|
||||
assert.equal(speed.textContent, '250');
|
||||
assert.equal(
|
||||
[...speed.children].every(
|
||||
(glyph) =>
|
||||
glyph.className === 'cockpit-roll-token' &&
|
||||
glyph.getAttribute('aria-hidden') === 'true',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(text('#cockpit-altitude-value'), '9,843');
|
||||
assert.equal(
|
||||
$('#cockpit-altitude-value').children[1].className,
|
||||
'cockpit-roll-token is-separator',
|
||||
);
|
||||
assert.equal(text('#cockpit-heading-value'), '092');
|
||||
assert.equal(text('#cockpit-speed-rim-value'), '250');
|
||||
const placed = ticks('speed').filter((tick) => !tick.hidden);
|
||||
assert.equal(placed.length, 9);
|
||||
assert.match(placed[0].style.getPropertyValue('--slot'), /^-?\d+\.\d{4}$/);
|
||||
assert.match(placed[0].style.getPropertyValue('--curve'), /^-?\d+\.\d{5}$/);
|
||||
assert.equal(
|
||||
placed.some((tick) => tick.classList.contains('major')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
placed.every((tick) => tick.querySelector('b').textContent !== ''),
|
||||
true,
|
||||
);
|
||||
const compass = [...$('#cockpit-compass-tape').children];
|
||||
assert.deepEqual(
|
||||
compass.map((division) => division.textContent),
|
||||
['N', '030', '060', 'E', '120', '150', 'S'],
|
||||
);
|
||||
assert.equal(compass[3].className, 'active');
|
||||
assert.equal(compass[0].style.getPropertyValue('--slot'), '-3');
|
||||
assert.equal(compass[0].style.getPropertyValue('--depth'), '3');
|
||||
assert.match(text('#cockpit-clock'), /^\d\d:\d\d:\d\dZ$/);
|
||||
assert.equal(text('#cockpit-position'), '30.198°N · 97.666°W');
|
||||
assert.equal($('#cockpit-hud').dataset.layer, 'flights');
|
||||
});
|
||||
|
||||
test('a missing speed hides the rim ticks where they stood and marks the rim', async (t) => {
|
||||
const { $, ticks, owner } = fixture(t);
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
await nextTick();
|
||||
const slot = ticks('speed')[0].style.getPropertyValue('--slot');
|
||||
updateHud.call(owner, { ...INFO, velocityMps: null }, 1100, true);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-speed-rim').classList.contains('unavailable'), true);
|
||||
assert.equal(
|
||||
ticks('speed').every((tick) => tick.hidden),
|
||||
true,
|
||||
);
|
||||
assert.equal(ticks('speed')[0].style.getPropertyValue('--slot'), slot);
|
||||
assert.equal(
|
||||
$('#cockpit-altitude-rim').classList.contains('unavailable'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
/** Stand in for the clock the odometer readouts throttle on. */
|
||||
function stubClock(t, startMs = 1000) {
|
||||
let nowMs = startMs;
|
||||
t.mock.method(performance, 'now', () => nowMs);
|
||||
return (ms) => {
|
||||
nowMs += ms;
|
||||
};
|
||||
}
|
||||
|
||||
const faster = (velocityMps) => ({ ...INFO, velocityMps });
|
||||
|
||||
test('a changed reading rolls its digits in fresh glyphs', async (t) => {
|
||||
const advance = stubClock(t);
|
||||
const { $, owner } = fixture(t);
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
await nextTick();
|
||||
const before = $('#cockpit-speed-value').firstElementChild;
|
||||
// 10 kt faster, outside the throttle.
|
||||
advance(300);
|
||||
updateHud.call(owner, faster(133.75), 1300);
|
||||
await nextTick();
|
||||
const speed = $('#cockpit-speed-value');
|
||||
assert.equal(speed.getAttribute('aria-label'), '260');
|
||||
assert.equal(speed.firstElementChild === before, false);
|
||||
const rolling = speed.querySelectorAll('.is-rolling.roll-up');
|
||||
assert.equal(rolling.length, 1);
|
||||
assert.equal(
|
||||
rolling[0].querySelector('.cockpit-roll-track').textContent,
|
||||
'56',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unchanged reading keeps the glyphs, so a roll is not restarted', async (t) => {
|
||||
const advance = stubClock(t);
|
||||
const { $, owner } = fixture(t);
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
await nextTick();
|
||||
advance(300);
|
||||
updateHud.call(owner, faster(133.75), 1300);
|
||||
await nextTick();
|
||||
const glyphs = [...$('#cockpit-speed-value').children];
|
||||
advance(100);
|
||||
updateHud.call(owner, faster(133.75), 1400);
|
||||
await nextTick();
|
||||
const after = [...$('#cockpit-speed-value').children];
|
||||
assert.equal(after.length, glyphs.length);
|
||||
assert.equal(
|
||||
after.every((glyph, index) => glyph === glyphs[index]),
|
||||
true,
|
||||
);
|
||||
assert.equal(after[1].classList.contains('is-rolling'), true);
|
||||
});
|
||||
|
||||
test('a change inside the throttle is dropped, and every update restamps it', async (t) => {
|
||||
const advance = stubClock(t);
|
||||
const { $, owner } = fixture(t);
|
||||
const speed = () => $('#cockpit-speed-value').getAttribute('aria-label');
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
await nextTick();
|
||||
// The same reading 150 ms later restamps the readout...
|
||||
advance(150);
|
||||
updateHud.call(owner, INFO, 1150);
|
||||
await nextTick();
|
||||
// ...so a change 200 ms after the first reading is still too soon.
|
||||
advance(50);
|
||||
updateHud.call(owner, faster(133.75), 1200);
|
||||
await nextTick();
|
||||
assert.equal(speed(), '250');
|
||||
// 220 ms after the restamp it shows.
|
||||
advance(170);
|
||||
updateHud.call(owner, faster(133.75), 1370);
|
||||
await nextTick();
|
||||
assert.equal(speed(), '260');
|
||||
});
|
||||
|
||||
test('an emergency and a military track restyle the HUD', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
updateHud.call(
|
||||
owner,
|
||||
{
|
||||
...INFO,
|
||||
layerId: 'military',
|
||||
emergency: {
|
||||
source: 'squawk',
|
||||
squawk: '7700',
|
||||
label: 'General emergency',
|
||||
severity: 'emergency',
|
||||
},
|
||||
},
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
await nextTick();
|
||||
const meta = $('#cockpit-aircraft-meta');
|
||||
assert.match(
|
||||
meta.textContent,
|
||||
/^MILITARY · SQUAWK 7700 · GENERAL EMERGENCY · LIVE TRACK$/,
|
||||
);
|
||||
assert.equal(meta.classList.contains('emergency'), true);
|
||||
assert.equal(meta.classList.contains('cockpit-aircraft-meta'), true);
|
||||
assert.equal($('#cockpit-hud').dataset.layer, 'military');
|
||||
updateHud.call(owner, INFO, 1100, true);
|
||||
await nextTick();
|
||||
assert.equal(meta.classList.contains('emergency'), false);
|
||||
assert.equal(text('#cockpit-aircraft-meta').startsWith('COMMERCIAL'), true);
|
||||
});
|
||||
|
||||
test('re-renders keep the visibility and classes the controller publishes on the HUD', async (t) => {
|
||||
const { $, owner, store } = fixture(t);
|
||||
const hud = $('#cockpit-hud');
|
||||
store.publishPresence({ active: true, context: false });
|
||||
hud.classList.add('voice-highlight');
|
||||
hud.style.setProperty('--voice-glow', '1');
|
||||
store.publishUtilityLayout({
|
||||
top: '120px',
|
||||
maxHeight: null,
|
||||
expandedMaxHeight: null,
|
||||
primaryOnly: false,
|
||||
hideCollapsed: false,
|
||||
});
|
||||
$('#cockpit-speed-rim').classList.add('external');
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
await nextTick();
|
||||
updateHud.call(owner, { ...INFO, velocityMps: null }, 1100, true);
|
||||
await nextTick();
|
||||
assert.equal(hud.hidden, false);
|
||||
assert.equal(hud.classList.contains('signals-active'), true);
|
||||
assert.equal(hud.classList.contains('voice-highlight'), true);
|
||||
assert.equal(hud.style.getPropertyValue('--voice-glow'), '1');
|
||||
// The utility strip's measured anchor renders from the store.
|
||||
assert.equal(hud.style.getPropertyValue('--cockpit-utility-top'), '120px');
|
||||
store.publishUtilityLayout(null);
|
||||
await nextTick();
|
||||
assert.equal(hud.style.getPropertyValue('--cockpit-utility-top'), '');
|
||||
assert.equal(hud.style.getPropertyValue('--voice-glow'), '1');
|
||||
assert.equal($('#cockpit-speed-rim').classList.contains('external'), true);
|
||||
});
|
||||
|
||||
test('a route shows the flight plan and the destination arrow', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
updateHud.call(owner, ROUTED, 1000, true);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-route').hidden, false);
|
||||
assert.equal(text('#cockpit-route-from'), 'AUS · Austin');
|
||||
assert.equal(text('#cockpit-route-to'), 'DEN · Denver');
|
||||
assert.equal(text('#cockpit-route-status'), 'ARROW · ESTIMATED DIRECTION');
|
||||
const arrow = $('#cockpit-route-direction');
|
||||
assert.equal(arrow.hidden, false);
|
||||
assert.match(
|
||||
arrow.style.getPropertyValue('--route-angle'),
|
||||
/^-?\d+\.\d\ddeg$/,
|
||||
);
|
||||
assert.match(text('#cockpit-route-direction-label'), /^DEST \d{3}°$/);
|
||||
});
|
||||
|
||||
test('a route without a destination fix keeps the card and hides the arrow where it pointed', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
updateHud.call(owner, ROUTED, 1000, true);
|
||||
await nextTick();
|
||||
const angle = $('#cockpit-route-direction').style.getPropertyValue(
|
||||
'--route-angle',
|
||||
);
|
||||
updateHud.call(
|
||||
owner,
|
||||
{
|
||||
...ROUTED,
|
||||
route: { origin: ROUTED.route.origin, destination: { code: 'DEN' } },
|
||||
},
|
||||
1100,
|
||||
true,
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-route').hidden, false);
|
||||
assert.equal(text('#cockpit-route-status'), 'ROUTE DATA UNAVAILABLE');
|
||||
assert.equal(text('#cockpit-route-to'), 'DEN');
|
||||
const arrow = $('#cockpit-route-direction');
|
||||
assert.equal(arrow.hidden, true);
|
||||
assert.equal(arrow.style.getPropertyValue('--route-angle'), angle);
|
||||
updateHud.call(owner, INFO, 1200, true);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-route').hidden, true);
|
||||
});
|
||||
|
||||
test('an unchanged route publishes nothing new; the instruments publish every update', (t) => {
|
||||
const { owner, published } = fixture(t);
|
||||
updateHud.call(owner, ROUTED, 1000, true);
|
||||
updateHud.call(owner, ROUTED, 1100, true);
|
||||
assert.equal(published.route, 1);
|
||||
assert.equal(published.instruments, 2);
|
||||
owner.routeView.visible = false;
|
||||
clearPredictiveRoute.call(owner);
|
||||
assert.equal(published.route, 2);
|
||||
});
|
||||
|
||||
test('the vision selector shows the mode the controller sets', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
const changes = [];
|
||||
owner.onVisionChange = (...args) => changes.push(args);
|
||||
owner.active = true;
|
||||
setVisionMode.call(owner, 'thermal', { revealParameters: true });
|
||||
await nextTick();
|
||||
const vision = $('#cockpit-vision-current');
|
||||
assert.equal(owner.visionMode, 'thermal');
|
||||
assert.equal(vision.dataset.cockpitVision, 'thermal');
|
||||
assert.equal(text('#cockpit-vision-current-label'), 'FLIR');
|
||||
assert.equal(
|
||||
vision.getAttribute('aria-label'),
|
||||
'Current cockpit vision style: Thermal. Activate for next style.',
|
||||
);
|
||||
assert.equal(vision.title, 'Current style: Thermal — click for next');
|
||||
assert.deepEqual(changes, [['thermal', true, { revealParameters: true }]]);
|
||||
});
|
||||
|
||||
test('the HUD reads in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, text, owner } = fixture(t);
|
||||
assert.equal(text('#cockpit-callsign'), 'САМОЛЁТ');
|
||||
updateHud.call(owner, INFO, 1000, true);
|
||||
setVisionMode.call(owner, 'nvg');
|
||||
await nextTick();
|
||||
assert.equal(text('#cockpit-altitude-value'), '9\u00a0843');
|
||||
assert.equal(
|
||||
$('#cockpit-altitude-value').children[1].className,
|
||||
'cockpit-roll-token is-separator',
|
||||
);
|
||||
assert.match(text('#cockpit-position'), /с\. ш\./);
|
||||
assert.match(
|
||||
$('#cockpit-vision-current').getAttribute('aria-label'),
|
||||
/^Текущий/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
|
||||
import {
|
||||
resolveCockpitUtilityAnchor,
|
||||
resolveCockpitUtilityLayout,
|
||||
resolveCyberCockpitPanelLane,
|
||||
} from '../cockpitUtilityLayout.js';
|
||||
import {
|
||||
COCKPIT_UTILITY_REC_GAP_PX,
|
||||
COCKPIT_UTILITY_SIGNAL_GAP_PX,
|
||||
COCKPIT_UTILITY_MIN_TOP_PX,
|
||||
COCKPIT_UTILITY_MIN_TOP_RATIO,
|
||||
COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
|
||||
isRenderedOnScreen,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
export function scheduleContextLayout() {
|
||||
if (this.destroyed) return false;
|
||||
// The published presence shows the cards on Vue's next flush, before this
|
||||
// frame runs, so it counts as visible already.
|
||||
const contextVisible =
|
||||
this.context && (this.presence?.context || !this.context.hidden);
|
||||
const signalVisible =
|
||||
this.signalStream && (this.presence?.active || !this.signalStream.hidden);
|
||||
if ((!contextVisible && !signalVisible) || this.contextLayoutFrame !== null)
|
||||
return;
|
||||
this.contextLayoutFrame = requestAnimationFrame(() => {
|
||||
this.contextLayoutFrame = null;
|
||||
if (this.destroyed) return;
|
||||
this.syncContextLayout();
|
||||
this.syncSignalLayout();
|
||||
});
|
||||
}
|
||||
|
||||
export function setContextCollapsed(collapsed) {
|
||||
const wasCollapsed = this.contextCollapsed;
|
||||
this.contextCollapsed = Boolean(collapsed);
|
||||
// The Contact card and its toggle render the collapse from the presence.
|
||||
if (this.presence.contextCollapsed !== this.contextCollapsed) {
|
||||
this.presence.contextCollapsed = this.contextCollapsed;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
if (this.active && wasCollapsed && !this.contextCollapsed) {
|
||||
window.dispatchEvent(new CustomEvent('gev:cockpit-context-expanded'));
|
||||
}
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
|
||||
export function setSignalCollapsed(collapsed, { user = false } = {}) {
|
||||
const wasCollapsed = this.signalCollapsed;
|
||||
this.signalCollapsed = Boolean(collapsed);
|
||||
if (user) this.signalUserCollapsed = this.signalCollapsed;
|
||||
// The briefing card and its toggle render the collapse from the presence.
|
||||
if (this.presence.signalCollapsed !== this.signalCollapsed) {
|
||||
this.presence.signalCollapsed = this.signalCollapsed;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
if (this.signalCollapsed) this.stopBriefRotation();
|
||||
else this.startBriefRotation({ reset: true });
|
||||
if (this.active && wasCollapsed && !this.signalCollapsed) {
|
||||
window.dispatchEvent(new CustomEvent('gev:cockpit-signal-expanded'));
|
||||
}
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
|
||||
export function syncContextLayout() {
|
||||
if (!this.context || this.context.hidden) return;
|
||||
if (window.matchMedia('(max-width: 760px)').matches) {
|
||||
this.context.dataset.layoutMode = 'compact-bottom';
|
||||
this.context.style.removeProperty('--cockpit-context-left');
|
||||
this.context.style.removeProperty('--cockpit-context-top');
|
||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||
return;
|
||||
}
|
||||
|
||||
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
|
||||
this.context.dataset.layoutMode = 'bottom-left';
|
||||
this.context.style.setProperty(
|
||||
'--cockpit-context-left',
|
||||
`${desktopInset.toFixed(1)}px`,
|
||||
);
|
||||
if (document.documentElement.dataset.uiTheme !== 'cyber') {
|
||||
this.context.style.removeProperty('--cockpit-context-top');
|
||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||
return;
|
||||
}
|
||||
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
|
||||
const dataPanel = document.querySelector(
|
||||
'#left-panel-stack > #data-panel:not(.collapsed)',
|
||||
);
|
||||
let desiredTop = defaultTop;
|
||||
if (this.contextCollapsed && dataPanel) {
|
||||
desiredTop = Math.max(
|
||||
desiredTop,
|
||||
dataPanel.getBoundingClientRect().bottom + 8,
|
||||
);
|
||||
}
|
||||
const contextBounds = this.context.getBoundingClientRect();
|
||||
let safeBottom = window.innerHeight - 24;
|
||||
for (const selector of [
|
||||
'#cesium-credits',
|
||||
'#command-dock',
|
||||
'#view-switcher',
|
||||
]) {
|
||||
const obstacle = document.querySelector(selector);
|
||||
if (!obstacle) continue;
|
||||
const style = window.getComputedStyle(obstacle);
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
Number(style.opacity) === 0
|
||||
)
|
||||
continue;
|
||||
const bounds = obstacle.getBoundingClientRect();
|
||||
const overlapsHorizontally =
|
||||
bounds.right > contextBounds.left && bounds.left < contextBounds.right;
|
||||
if (overlapsHorizontally && bounds.top > desiredTop) {
|
||||
safeBottom = Math.min(safeBottom, bounds.top - 8);
|
||||
}
|
||||
}
|
||||
const boundedTop = Math.max(
|
||||
defaultTop,
|
||||
Math.min(desiredTop, safeBottom - contextBounds.height),
|
||||
);
|
||||
this.context.style.setProperty(
|
||||
'--cockpit-context-top',
|
||||
`${boundedTop.toFixed(1)}px`,
|
||||
);
|
||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||
}
|
||||
|
||||
export function syncSignalLayout() {
|
||||
if (!this.signalStream || this.signalStream.hidden) return;
|
||||
const utilityControls = document.getElementById('cockpit-utility-controls');
|
||||
if (window.matchMedia('(max-width: 760px)').matches) {
|
||||
this.signalStream.dataset.layoutMode = 'compact-top';
|
||||
// The compact strip shows only an expanded control.
|
||||
this.publishUtilityLayout(utilityLayoutView({ hideCollapsed: true }));
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-right');
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-top');
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
|
||||
return;
|
||||
}
|
||||
|
||||
const desktopInset = Math.max(24, Math.min(58, window.innerWidth * 0.04));
|
||||
this.signalStream.dataset.layoutMode = 'bottom-right';
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-right',
|
||||
`${desktopInset.toFixed(1)}px`,
|
||||
);
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-top');
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
|
||||
const cyber = document.documentElement.dataset.uiTheme === 'cyber';
|
||||
const leftPanel = cyber
|
||||
? document.querySelector('#left-panel-stack > #data-panel')
|
||||
: null;
|
||||
const alignedTop = isRenderedOnScreen(leftPanel)
|
||||
? leftPanel.getBoundingClientRect().top
|
||||
: null;
|
||||
// Cyber's two rails share a top edge. Move the briefing below the collapsed
|
||||
// strip rather than pulling the right rail upward to avoid it.
|
||||
if (alignedTop !== null && utilityControls) {
|
||||
const expanded = utilityControls.querySelector(
|
||||
'.cockpit-utility-control.is-expanded',
|
||||
);
|
||||
if (!expanded) {
|
||||
const signalTop = Math.max(
|
||||
Math.max(255, Math.min(300, window.innerHeight * 0.28)),
|
||||
alignedTop + utilityControls.getBoundingClientRect().height + 28,
|
||||
);
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-top',
|
||||
`${signalTop.toFixed(1)}px`,
|
||||
);
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-max-height',
|
||||
`${Math.max(44, window.innerHeight - signalTop - 80).toFixed(1)}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const signalBounds = this.signalStream.getBoundingClientRect();
|
||||
const utilityBounds = utilityControls?.getBoundingClientRect();
|
||||
if (utilityBounds) {
|
||||
const expandedControl = utilityControls?.querySelector(
|
||||
'.cockpit-utility-control.is-expanded',
|
||||
);
|
||||
const collapsedControl = utilityControls?.querySelector(
|
||||
'.cockpit-utility-control:not(.is-expanded)',
|
||||
);
|
||||
const collapsedLauncher = collapsedControl?.querySelector(
|
||||
'.cockpit-utility-launcher',
|
||||
);
|
||||
const expandedHeight = expandedControl
|
||||
? Math.max(
|
||||
expandedControl.scrollHeight,
|
||||
expandedControl.getBoundingClientRect().height,
|
||||
)
|
||||
: 0;
|
||||
const collapsedHeight = collapsedLauncher
|
||||
? Math.max(
|
||||
COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX,
|
||||
collapsedLauncher.scrollHeight,
|
||||
)
|
||||
: 0;
|
||||
if (alignedTop !== null) {
|
||||
const lane = resolveCyberCockpitPanelLane({
|
||||
viewportHeight: window.innerHeight,
|
||||
top: alignedTop,
|
||||
launcherHeight: Math.max(
|
||||
collapsedHeight,
|
||||
collapsedLauncher?.getBoundingClientRect().height || 60,
|
||||
),
|
||||
signalHeight: this.signalCollapsed ? signalBounds.height : 44,
|
||||
contactHeight:
|
||||
this.contextCollapsed && this.context
|
||||
? this.context.getBoundingClientRect().height
|
||||
: 64,
|
||||
});
|
||||
document.body.style.setProperty(
|
||||
'--cyber-cockpit-panel-height',
|
||||
`${lane.panelHeight.toFixed(2)}px`,
|
||||
);
|
||||
this.publishUtilityLayout(
|
||||
utilityLayoutView({
|
||||
top: `${alignedTop.toFixed(2)}px`,
|
||||
maxHeight: `${lane.utilityHeight.toFixed(2)}px`,
|
||||
expandedMaxHeight: `${lane.panelHeight.toFixed(2)}px`,
|
||||
}),
|
||||
);
|
||||
if (expandedControl) {
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-top',
|
||||
`${lane.signalTop.toFixed(2)}px`,
|
||||
);
|
||||
}
|
||||
this.syncContextLayout();
|
||||
return;
|
||||
}
|
||||
// Non-Cyber themes retain their independent REC/briefing corridor. Cyber
|
||||
// uses the rendered Data Layers top after reserving briefing space above.
|
||||
// The readout only anchors the strip while it is genuinely on screen:
|
||||
// the Minimal variant drops it with `display:none`, but HUD Off hides the
|
||||
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
|
||||
const recReadout = document.querySelector('#intel-hud .hud-top-right');
|
||||
const recBounds = isRenderedOnScreen(recReadout)
|
||||
? recReadout.getBoundingClientRect()
|
||||
: null;
|
||||
const utilityAnchor = resolveCockpitUtilityAnchor({
|
||||
recBottom: recBounds ? recBounds.bottom : 0,
|
||||
signalTop: signalBounds.top,
|
||||
stripHeight: utilityBounds.height,
|
||||
viewportHeight: window.innerHeight,
|
||||
collapsedHeight,
|
||||
recGap: COCKPIT_UTILITY_REC_GAP_PX,
|
||||
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
|
||||
minTopFloor: alignedTop ?? COCKPIT_UTILITY_MIN_TOP_PX,
|
||||
minTopRatio: alignedTop !== null ? 0 : COCKPIT_UTILITY_MIN_TOP_RATIO,
|
||||
signalCollapsed: this.signalCollapsed,
|
||||
utilityExpanded: Boolean(expandedControl),
|
||||
// Only Cyber relocates its collapsed briefing below the utility lane.
|
||||
// Other themes keep the measured visible card as the lower boundary.
|
||||
reserveViewportLane: document.documentElement.dataset.uiTheme === 'cyber',
|
||||
// Cyber keeps the collapsed briefing reachable below the utility instead
|
||||
// of hiding it. Reserve its actual height, including wrapped headings.
|
||||
signalHeight:
|
||||
document.documentElement.dataset.uiTheme === 'cyber' &&
|
||||
this.signalCollapsed
|
||||
? signalBounds.height
|
||||
: 0,
|
||||
});
|
||||
const availableHeight = utilityAnchor.maxHeight;
|
||||
const utilityLayout =
|
||||
expandedControl && collapsedControl
|
||||
? resolveCockpitUtilityLayout({
|
||||
availableHeight,
|
||||
expandedHeight,
|
||||
collapsedHeight,
|
||||
})
|
||||
: { primaryOnly: false, expandedMaxHeight: availableHeight };
|
||||
this.publishUtilityLayout(
|
||||
utilityLayoutView({
|
||||
top: `${utilityAnchor.top.toFixed(1)}px`,
|
||||
maxHeight: `${availableHeight.toFixed(2)}px`,
|
||||
expandedMaxHeight: `${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
|
||||
primaryOnly: utilityLayout.primaryOnly,
|
||||
hideCollapsed: utilityLayout.primaryOnly,
|
||||
}),
|
||||
);
|
||||
// Focus leaves a control the strip is about to hide.
|
||||
if (
|
||||
utilityLayout.primaryOnly &&
|
||||
collapsedControl?.contains(document.activeElement)
|
||||
) {
|
||||
expandedControl
|
||||
?.querySelector('.cockpit-utility-glyph')
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The utility strip's layout as CockpitView and CockpitUtilityControls
|
||||
* render it: its anchor and height limits on the HUD, and which controls fit.
|
||||
*/
|
||||
function utilityLayoutView({
|
||||
top = null,
|
||||
maxHeight = null,
|
||||
expandedMaxHeight = null,
|
||||
primaryOnly = false,
|
||||
hideCollapsed = false,
|
||||
} = {}) {
|
||||
return { top, maxHeight, expandedMaxHeight, primaryOnly, hideCollapsed };
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// src/ui/cockpitPresence.test.mjs — the Cockpit entry, TR-3B chip, HUD, exit
|
||||
// controls and Contact card show what the Cockpit controller publishes, and
|
||||
// focus moves once they render, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue';
|
||||
import { CockpitViewController } from './cockpitController.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
|
||||
const INFO = Object.freeze({
|
||||
icao24: 'abc123',
|
||||
callsign: 'UAL123',
|
||||
track: 92,
|
||||
layerId: 'flights',
|
||||
});
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(
|
||||
defineComponent(() => () => [h(ContextView), h(CockpitView)]),
|
||||
).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useCockpitStore(pinia);
|
||||
// A browser does not focus a hidden control; happy-dom does, so refuse
|
||||
// here the way a browser would.
|
||||
const hiddenFocus = [];
|
||||
for (const id of ['cockpit-entry', 'map-view-switch']) {
|
||||
const button = document.getElementById(id);
|
||||
const focus = button.focus.bind(button);
|
||||
button.focus = (options) => {
|
||||
if (button.hidden) hiddenFocus.push(id);
|
||||
else focus(options);
|
||||
};
|
||||
}
|
||||
const converted = new Set();
|
||||
const listeners = () => ({ addEventListener: () => () => {} });
|
||||
const entity = { position: {}, show: true };
|
||||
const viewer = {
|
||||
trackedEntity: entity,
|
||||
camera: { cancelFlight() {} },
|
||||
entities: { contains: (candidate) => candidate === entity },
|
||||
scene: {
|
||||
preUpdate: listeners(),
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
},
|
||||
trackedEntityChanged: listeners(),
|
||||
};
|
||||
const cockpit = new CockpitViewController(viewer, {
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: () => INFO, refreshTr3b() {} },
|
||||
militaryFlightsLayer: { getTrackedInfo: () => null },
|
||||
isTr3b: (icao24) => converted.has(icao24),
|
||||
toggleTr3b: (icao24) => converted.delete(icao24) || converted.add(icao24),
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
},
|
||||
publish: {
|
||||
presence: (view) => store.publishPresence(view),
|
||||
},
|
||||
});
|
||||
// Entry and exit drive the HUD, briefing and route; their own tests cover
|
||||
// those, so this one keeps only what shows and hides.
|
||||
Object.assign(cockpit, {
|
||||
readAircraftInfo: () => INFO,
|
||||
updateHud() {},
|
||||
setVisionMode() {},
|
||||
showBriefPage() {},
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
pushCockpitSignal() {},
|
||||
scheduleContextLayout() {},
|
||||
clearPredictiveRoute() {},
|
||||
});
|
||||
t.after(() => {
|
||||
cockpit.stop();
|
||||
app.unmount();
|
||||
document.body.classList.remove('cockpit-mode');
|
||||
});
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return { $, cockpit, store, converted, hiddenFocus };
|
||||
}
|
||||
|
||||
const shown = ($) => ({
|
||||
entry: !$('#cockpit-entry').hidden,
|
||||
tr3b: !$('#tr3b-toggle').hidden,
|
||||
hud: !$('#cockpit-hud').hidden,
|
||||
exit: !$('#map-view-switch').hidden,
|
||||
reset: !$('#cockpit-reset-globe').hidden,
|
||||
briefing: !$('#cockpit-signal-stream').hidden,
|
||||
});
|
||||
|
||||
test('before anything is tracked, every Cockpit surface is hidden', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
assert.deepEqual(shown($), {
|
||||
entry: false,
|
||||
tr3b: false,
|
||||
hud: false,
|
||||
exit: false,
|
||||
reset: false,
|
||||
briefing: false,
|
||||
});
|
||||
assert.equal($('#cockpit-context').hidden, true);
|
||||
});
|
||||
|
||||
test('a tracked aircraft shows the entry and a TR-3B chip that names its action', async (t) => {
|
||||
const { $, cockpit } = fixture(t);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.deepEqual(shown($), {
|
||||
entry: true,
|
||||
tr3b: true,
|
||||
hud: false,
|
||||
exit: false,
|
||||
reset: false,
|
||||
briefing: false,
|
||||
});
|
||||
const chip = $('#tr3b-toggle');
|
||||
assert.equal(chip.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(chip.title, 'Reclassify as TR-3B');
|
||||
chip.click();
|
||||
await nextTick();
|
||||
assert.equal(chip.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(chip.title, 'Restore real aircraft');
|
||||
// The static accessible name survives the re-render.
|
||||
assert.equal(
|
||||
chip.getAttribute('aria-label'),
|
||||
'Reclassify tracked contact as TR-3B',
|
||||
);
|
||||
});
|
||||
|
||||
test('entering shows the HUD and focuses the exit once it renders; exiting returns focus to the entry', async (t) => {
|
||||
const { $, cockpit } = fixture(t);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
const entry = $('#cockpit-entry');
|
||||
entry.focus();
|
||||
entry.click();
|
||||
assert.equal(cockpit.active, true);
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(shown($), {
|
||||
entry: false,
|
||||
tr3b: false,
|
||||
hud: true,
|
||||
exit: true,
|
||||
reset: true,
|
||||
briefing: true,
|
||||
});
|
||||
const hud = $('#cockpit-hud');
|
||||
assert.equal(hud.classList.contains('signals-active'), true);
|
||||
assert.equal(document.activeElement?.id, 'map-view-switch');
|
||||
|
||||
$('#map-view-switch').click();
|
||||
assert.equal(cockpit.active, false);
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(shown($), {
|
||||
entry: true,
|
||||
tr3b: true,
|
||||
hud: false,
|
||||
exit: false,
|
||||
reset: false,
|
||||
briefing: false,
|
||||
});
|
||||
assert.equal(hud.classList.contains('signals-active'), false);
|
||||
assert.equal(document.activeElement?.id, 'cockpit-entry');
|
||||
});
|
||||
|
||||
test('exiting leaves focus alone when the entry is no longer available', async (t) => {
|
||||
const { $, cockpit, hiddenFocus } = fixture(t);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
$('#cockpit-entry').focus();
|
||||
cockpit.enter();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
cockpit.isEntryAllowed = () => false;
|
||||
cockpit.exit();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
assert.equal($('#cockpit-entry').hidden, true);
|
||||
assert.notEqual(document.activeElement?.id, 'cockpit-entry');
|
||||
assert.deepEqual(hiddenFocus, []);
|
||||
});
|
||||
|
||||
test('a second entry hides the entry again and focuses the exit', async (t) => {
|
||||
const { $, cockpit, hiddenFocus } = fixture(t);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
for (let pass = 0; pass < 2; pass += 1) {
|
||||
$('#cockpit-entry').focus();
|
||||
cockpit.enter();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
assert.equal($('#cockpit-entry').hidden, true);
|
||||
assert.equal($('#tr3b-toggle').hidden, true);
|
||||
assert.equal(document.activeElement?.id, 'map-view-switch');
|
||||
cockpit.exit();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
assert.equal($('#cockpit-entry').hidden, false);
|
||||
assert.equal($('#tr3b-toggle').hidden, false);
|
||||
assert.equal(document.activeElement?.id, 'cockpit-entry');
|
||||
}
|
||||
assert.deepEqual(hiddenFocus, []);
|
||||
});
|
||||
|
||||
test('disposing an active Cockpit hides the HUD and leaves the entry hidden', async (t) => {
|
||||
const { $, cockpit } = fixture(t);
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
cockpit.enter();
|
||||
await nextTick();
|
||||
cockpit.dispose();
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
assert.equal($('#cockpit-hud').hidden, true);
|
||||
assert.equal($('#map-view-switch').hidden, true);
|
||||
assert.equal($('#cockpit-entry').hidden, true);
|
||||
assert.notEqual(document.activeElement?.id, 'cockpit-entry');
|
||||
});
|
||||
|
||||
test('the Contact card and the HUD mark show and hide together', async (t) => {
|
||||
const { $, cockpit } = fixture(t);
|
||||
const hud = $('#cockpit-hud');
|
||||
hud.classList.add('voice-highlight');
|
||||
cockpit.showContextCard(true);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').hidden, false);
|
||||
assert.equal(hud.classList.contains('context-active'), true);
|
||||
cockpit.showContextCard(false);
|
||||
await nextTick();
|
||||
assert.equal($('#cockpit-context').hidden, true);
|
||||
assert.equal(hud.classList.contains('context-active'), false);
|
||||
assert.equal(hud.classList.contains('voice-highlight'), true);
|
||||
});
|
||||
|
||||
test('entering asks for a panel layout before Vue shows the cards', async (t) => {
|
||||
const { cockpit } = fixture(t);
|
||||
delete cockpit.scheduleContextLayout;
|
||||
const laid = [];
|
||||
cockpit.syncContextLayout = () => laid.push('context');
|
||||
cockpit.syncSignalLayout = () => laid.push('signals');
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
cockpit.enter();
|
||||
assert.notEqual(cockpit.contextLayoutFrame, null);
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
assert.deepEqual(laid, ['context', 'signals']);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
import {
|
||||
normalizeHeading,
|
||||
roundHeadingDegrees,
|
||||
} from '../data/headingDegrees.js';
|
||||
import { kilometreDigits } from '../data/distanceFormat.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
|
||||
export const COCKPIT_FORWARD_OFFSET_M = 7;
|
||||
|
||||
export const COCKPIT_UP_OFFSET_M = 2.6;
|
||||
|
||||
export const COCKPIT_MIN_GROUND_CLEARANCE_M = 12;
|
||||
|
||||
export const COCKPIT_VIEW_PITCH_DEG = -4;
|
||||
|
||||
export const COCKPIT_CAMERA_UPDATE_MS = 50;
|
||||
|
||||
export const COCKPIT_HUD_UPDATE_MS = 100;
|
||||
|
||||
export const COCKPIT_CONTEXT_UPDATE_MS = 250;
|
||||
|
||||
export const COCKPIT_UTILITY_REC_GAP_PX = 12;
|
||||
|
||||
export const COCKPIT_UTILITY_SIGNAL_GAP_PX = 8;
|
||||
|
||||
export const COCKPIT_UTILITY_MIN_TOP_PX = 96;
|
||||
|
||||
export const COCKPIT_UTILITY_MIN_TOP_RATIO = 0.12;
|
||||
|
||||
export const COCKPIT_UTILITY_LAUNCHER_MIN_HEIGHT_PX = 50;
|
||||
|
||||
export const COCKPIT_GROUND_PROBE_MS = 500;
|
||||
|
||||
export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
|
||||
|
||||
export const COCKPIT_BRIEF_ROTATE_MS = 9000;
|
||||
|
||||
export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
|
||||
|
||||
export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
|
||||
|
||||
export function isRenderedOnScreen(element) {
|
||||
if (!element) return false;
|
||||
for (let node = element; node instanceof Element; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
Number(style.opacity) === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
export function formatCockpitBriefAge(value) {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return t('TIME UNKNOWN');
|
||||
const minutes = Math.max(0, Math.round((Date.now() - timestamp) / 60_000));
|
||||
if (minutes < 60) return t('{count}M AGO', { count: minutes });
|
||||
const hours = Math.round(minutes / 60);
|
||||
return hours < 48
|
||||
? t('{count}H AGO', { count: hours })
|
||||
: t('{count}D AGO', { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
/** Compass point codes in display order and the label shown for each. */
|
||||
const COMPASS_POINT_LABELS = Object.freeze({
|
||||
N: msg('N'),
|
||||
NE: msg('NE'),
|
||||
E: msg('E'),
|
||||
SE: msg('SE'),
|
||||
S: msg('S'),
|
||||
SW: msg('SW'),
|
||||
W: msg('W'),
|
||||
NW: msg('NW'),
|
||||
});
|
||||
|
||||
/**
|
||||
* The shown label for a compass point code (`N`, `SW`…): С, ЮЗ… in Russian.
|
||||
* Anything else (a degree reading such as `030`) passes through unchanged.
|
||||
*/
|
||||
export function compassPointLabel(code) {
|
||||
return Object.hasOwn(COMPASS_POINT_LABELS, code)
|
||||
? t(COMPASS_POINT_LABELS[code])
|
||||
: code;
|
||||
}
|
||||
|
||||
/** `30.203°N` in the active language: `30,203° с. ш.` in Russian. */
|
||||
export function formatCockpitCoordinate(value, positive, negative) {
|
||||
const amount = formatNumber(Math.abs(value), {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 3,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
const code = value >= 0 ? positive : negative;
|
||||
// Not «30,203°С»: after a degree sign «С» reads as Celsius. Isolated, so
|
||||
// an Arabic hemisphere letter does not pull the next number into its run.
|
||||
switch (code) {
|
||||
case 'N':
|
||||
return isolate(t('{value}°N', { value: amount }));
|
||||
case 'S':
|
||||
return isolate(t('{value}°S', { value: amount }));
|
||||
case 'E':
|
||||
return isolate(t('{value}°E', { value: amount }));
|
||||
case 'W':
|
||||
return isolate(t('{value}°W', { value: amount }));
|
||||
default:
|
||||
return isolate(`${amount}°${compassPointLabel(code)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A contact distance such as `4.2 KM` / `44 KM` in the active language
|
||||
* (`4,2 КМ`): one decimal under 10 km, whole kilometres beyond.
|
||||
*/
|
||||
export function formatCockpitDistance(distanceM) {
|
||||
const digits = kilometreDigits(distanceM);
|
||||
const distance = formatQuantity(distanceM / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
return cockpitUnitCase(distance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper-case a cockpit unit the HUD way (`9.5 KM`), except in Chinese and
|
||||
* Japanese, which have no case and keep units as the catalog writes them.
|
||||
*/
|
||||
export function cockpitUnitCase(text) {
|
||||
return /^(zh|ja)(-|$)/.test(getLocale()) ? text : text.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = Object.keys(COMPASS_POINT_LABELS);
|
||||
const normalized = normalizeHeading(value);
|
||||
const degrees = roundHeadingDegrees(value);
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatCockpitDistance,
|
||||
formatCockpitWindDirection,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
test('wind just west of north reads 0°, not 360°', () => {
|
||||
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
|
||||
assert.equal(formatCockpitWindDirection(0), 'N · 0°');
|
||||
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
|
||||
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
|
||||
});
|
||||
|
||||
test('a cockpit distance that rounds to 10.0 km drops the decimal', () => {
|
||||
assert.match(formatCockpitDistance(9949), /^9\.9\s?KM$/);
|
||||
assert.match(formatCockpitDistance(9960), /^10\s?KM$/);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The Cockpit odometer readouts (ground speed, altitude, heading): which
|
||||
* glyphs a reading shows and which of them roll. It has no imports, so the
|
||||
* view that renders the glyphs can read it.
|
||||
*/
|
||||
|
||||
/** The shortest gap between two rolls, in ms. */
|
||||
const ROLL_THROTTLE_MS = 220;
|
||||
|
||||
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
|
||||
// narrow cell; a full digit cell would open a gap in the middle of a number.
|
||||
const ROLL_SEPARATOR = /^[,.\u00a0\u202f]$/;
|
||||
|
||||
function rollTokenClass(character) {
|
||||
return ROLL_SEPARATOR.test(character)
|
||||
? 'cockpit-roll-token is-separator'
|
||||
: 'cockpit-roll-token';
|
||||
}
|
||||
|
||||
/**
|
||||
* A readout before its first reading: no glyphs, so the view shows its
|
||||
* placeholder.
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createRollingState() {
|
||||
return {
|
||||
text: undefined,
|
||||
value: NaN,
|
||||
at: NaN,
|
||||
tokens: null,
|
||||
generation: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a reading. A changed reading inside the throttle is dropped; any
|
||||
* other reading restamps the readout. A changed reading rolls each digit
|
||||
* that differs, unless it is the first or `immediate`, which draw it flat.
|
||||
*
|
||||
* @param {object} state From createRollingState or an earlier call.
|
||||
* @param {string} text The reading as shown.
|
||||
* @param {?number} numericValue Its value, for the roll direction.
|
||||
* @param {{circularRange?: ?number, immediate?: boolean, nowMs?: number}} [options]
|
||||
* @returns {object} `state` itself when its glyphs stay, else a new state
|
||||
* with a new `generation`, so every glyph is drawn afresh and its roll
|
||||
* animation restarts.
|
||||
*/
|
||||
export function advanceRollingValue(
|
||||
state,
|
||||
text,
|
||||
numericValue,
|
||||
{ circularRange = null, immediate = false, nowMs = performance.now() } = {},
|
||||
) {
|
||||
const nextText = String(text);
|
||||
const previousText = state.text;
|
||||
const previousValue = state.value;
|
||||
if (
|
||||
!immediate &&
|
||||
previousText !== undefined &&
|
||||
previousText !== nextText &&
|
||||
Number.isFinite(state.at) &&
|
||||
nowMs - state.at < ROLL_THROTTLE_MS
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
state.at = nowMs;
|
||||
state.value = Number.isFinite(numericValue) ? numericValue : NaN;
|
||||
if (previousText === nextText && state.tokens) return state;
|
||||
|
||||
const next = { ...state, text: nextText, generation: state.generation + 1 };
|
||||
if (immediate || previousText === undefined || previousText === nextText) {
|
||||
next.tokens = Array.from(nextText, (character) => ({
|
||||
className: rollTokenClass(character),
|
||||
text: character,
|
||||
track: null,
|
||||
}));
|
||||
return next;
|
||||
}
|
||||
|
||||
let delta =
|
||||
Number.isFinite(numericValue) && Number.isFinite(previousValue)
|
||||
? numericValue - previousValue
|
||||
: 0;
|
||||
if (Number.isFinite(circularRange) && circularRange > 0) {
|
||||
const halfRange = circularRange / 2;
|
||||
if (delta > halfRange) delta -= circularRange;
|
||||
else if (delta < -halfRange) delta += circularRange;
|
||||
}
|
||||
const direction = delta < 0 ? 'down' : 'up';
|
||||
const width = Math.max(previousText.length, nextText.length);
|
||||
const from = previousText.padStart(width, ' ');
|
||||
const to = nextText.padStart(width, ' ');
|
||||
next.tokens = Array.from(to, (nextCharacter, index) => {
|
||||
const previousCharacter = from[index];
|
||||
if (
|
||||
previousCharacter === nextCharacter ||
|
||||
!/\d/.test(previousCharacter) ||
|
||||
!/\d/.test(nextCharacter)
|
||||
) {
|
||||
return {
|
||||
className: rollTokenClass(nextCharacter),
|
||||
text: nextCharacter === ' ' ? '\u00a0' : nextCharacter,
|
||||
track: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: `${rollTokenClass(nextCharacter)} is-rolling roll-${direction}`,
|
||||
text: null,
|
||||
track:
|
||||
direction === 'up'
|
||||
? [previousCharacter, nextCharacter]
|
||||
: [nextCharacter, previousCharacter],
|
||||
};
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// src/ui/cockpitRollingValue.test.mjs — which glyphs a Cockpit odometer
|
||||
// readout shows, and which of them roll.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
advanceRollingValue,
|
||||
createRollingState,
|
||||
} from './cockpitRollingValue.js';
|
||||
|
||||
const glyphs = (state) =>
|
||||
state.tokens.map((token) => token.text ?? token.track.join('>')).join('|');
|
||||
|
||||
test('the first reading draws flat, one glyph per character', () => {
|
||||
const state = advanceRollingValue(createRollingState(), '1,250', 1250, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
assert.equal(state.text, '1,250');
|
||||
assert.equal(glyphs(state), '1|,|2|5|0');
|
||||
assert.deepEqual(
|
||||
state.tokens.map((token) => token.className),
|
||||
[
|
||||
'cockpit-roll-token',
|
||||
'cockpit-roll-token is-separator',
|
||||
'cockpit-roll-token',
|
||||
'cockpit-roll-token',
|
||||
'cockpit-roll-token',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('a changed reading rolls only the digits that differ, upward when rising', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '248', 248, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
const next = advanceRollingValue(first, '251', 251, { nowMs: 1300 });
|
||||
assert.equal(next === first, false);
|
||||
assert.equal(next.generation, first.generation + 1);
|
||||
assert.equal(glyphs(next), '2|4>5|8>1');
|
||||
assert.equal(
|
||||
next.tokens[1].className,
|
||||
'cockpit-roll-token is-rolling roll-up',
|
||||
);
|
||||
assert.equal(next.tokens[0].className, 'cockpit-roll-token');
|
||||
});
|
||||
|
||||
test('a falling reading rolls down, and a longer one pads its new columns', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '99', 99, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
const down = advanceRollingValue(first, '98', 98, { nowMs: 1300 });
|
||||
assert.equal(glyphs(down), '9|8>9');
|
||||
assert.match(down.tokens[1].className, /roll-down$/);
|
||||
const wider = advanceRollingValue(down, '100', 100, { nowMs: 1600 });
|
||||
assert.equal(glyphs(wider), '1|9>0|8>0');
|
||||
const narrower = advanceRollingValue(wider, '7', 7, { nowMs: 1900 });
|
||||
assert.equal(glyphs(narrower), '\u00a0|\u00a0|7>0');
|
||||
});
|
||||
|
||||
test('heading wraps the short way round the circle', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '359', 359, {
|
||||
circularRange: 360,
|
||||
nowMs: 1000,
|
||||
});
|
||||
const next = advanceRollingValue(first, '001', 1, {
|
||||
circularRange: 360,
|
||||
nowMs: 1300,
|
||||
});
|
||||
assert.match(next.tokens[1].className, /roll-up$/);
|
||||
});
|
||||
|
||||
test('a change inside the throttle is dropped; any accepted reading restamps it', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '100', 100, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
// The same reading 150 ms later is accepted and restamps the readout.
|
||||
const same = advanceRollingValue(first, '100', 100, { nowMs: 1150 });
|
||||
assert.equal(same === first, true);
|
||||
assert.equal(same.at, 1150);
|
||||
// So a change 200 ms after the first reading is still inside the throttle.
|
||||
const early = advanceRollingValue(same, '110', 110, { nowMs: 1200 });
|
||||
assert.equal(early === same, true);
|
||||
assert.equal(early.text, '100');
|
||||
assert.equal(early.at, 1150);
|
||||
const late = advanceRollingValue(early, '110', 110, { nowMs: 1370 });
|
||||
assert.equal(late.text, '110');
|
||||
});
|
||||
|
||||
test('an immediate reading skips the throttle and draws flat', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '100', 100, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
const next = advanceRollingValue(first, '120', 120, {
|
||||
immediate: true,
|
||||
nowMs: 1010,
|
||||
});
|
||||
assert.equal(glyphs(next), '1|2|0');
|
||||
assert.equal(
|
||||
next.tokens.every((token) => token.track === null),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('a reading with no value keeps rolling upward', () => {
|
||||
const first = advanceRollingValue(createRollingState(), '---', null, {
|
||||
nowMs: 1000,
|
||||
});
|
||||
assert.equal(Number.isNaN(first.value), true);
|
||||
const next = advanceRollingValue(first, '120', 120, { nowMs: 1300 });
|
||||
// Dashes are not digits, so nothing rolls.
|
||||
assert.equal(glyphs(next), '1|2|0');
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/** The Cockpit signal rows: which contacts and statuses they show, and their selection. */
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { formatCockpitDistance } from './cockpitPresentation.js';
|
||||
|
||||
/**
|
||||
* Publish the signal rows. A row's key includes its action target, so a
|
||||
* reused status key never turns a focused flight button into a different
|
||||
* selection; repeats of a key are numbered so each keeps its own row.
|
||||
*/
|
||||
export function publishCockpitSignals() {
|
||||
const seen = new Map();
|
||||
this.publishSignals(
|
||||
this.signalItems.map((item) => {
|
||||
const identity = JSON.stringify([
|
||||
item.key,
|
||||
Boolean(item.target),
|
||||
item.target?.layerId || '',
|
||||
String(item.target?.id ?? ''),
|
||||
]);
|
||||
const repeat = seen.get(identity) ?? 0;
|
||||
seen.set(identity, repeat + 1);
|
||||
return {
|
||||
key: repeat ? `${identity}#${repeat}` : identity,
|
||||
className: item.target ? `${item.tone} actionable` : item.tone,
|
||||
time: new Date(item.timestamp).toISOString().slice(11, 19) + 'Z',
|
||||
title: item.title,
|
||||
detail: item.detail,
|
||||
target: item.target && {
|
||||
layerId: item.target.layerId,
|
||||
id: item.target.id,
|
||||
label: t('Select flight {title}', { title: item.title }),
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
this.scheduleContextLayout();
|
||||
}
|
||||
|
||||
export function pushCockpitSignal(key, tone, title, detail, target = null) {
|
||||
if (!title || !detail) return;
|
||||
const signature = `${title}|${detail}|${target?.layerId || ''}|${target?.id || ''}`;
|
||||
if (this.signalSignatures.get(key) === signature) return;
|
||||
this.signalSignatures.set(key, signature);
|
||||
// A locale change words the signal again where it stands.
|
||||
const shown = this.rewordingSignals
|
||||
? this.signalItems.find((item) => item.key === key)
|
||||
: null;
|
||||
if (shown) {
|
||||
Object.assign(shown, { title, detail });
|
||||
this.publishCockpitSignals();
|
||||
return;
|
||||
}
|
||||
this.signalItems.unshift({
|
||||
key,
|
||||
tone,
|
||||
title,
|
||||
detail,
|
||||
target,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.signalItems = this.signalItems.slice(0, 5);
|
||||
this.publishCockpitSignals();
|
||||
}
|
||||
|
||||
export function updateCockpitSignals(snapshot, unknownCount) {
|
||||
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
|
||||
const contacts = [];
|
||||
const subject = snapshot.subject;
|
||||
if (['flights', 'military'].includes(subject?.layerId) && subject?.id) {
|
||||
contacts.push({
|
||||
key: `flight:${subject.layerId}:${subject.id}`,
|
||||
tone: 'track',
|
||||
title: subject.label || subject.id,
|
||||
detail: t('{aircraftClass} · CURRENT', {
|
||||
aircraftClass:
|
||||
subject.layerId === 'military'
|
||||
? t('MILITARY FLIGHT')
|
||||
: t('COMMERCIAL FLIGHT'),
|
||||
}),
|
||||
target: { layerId: subject.layerId, id: String(subject.id) },
|
||||
distanceM: -1,
|
||||
});
|
||||
}
|
||||
for (const cohort of snapshot.cohorts) {
|
||||
if (!['flights', 'military'].includes(cohort.id)) continue;
|
||||
for (const item of cohort.nearest) {
|
||||
const id = item.icao24 || item.id;
|
||||
if (!id) continue;
|
||||
contacts.push({
|
||||
key: `flight:${cohort.id}:${id}`,
|
||||
tone: cohort.id === 'military' ? 'nearby military' : 'nearby',
|
||||
// `id` above is IDENTITY (the ICAO hex). Display must not reuse it:
|
||||
// the row's own `id` already carries the layer's label convention
|
||||
// (callsign → registration → hex), so a callsign-less enriched
|
||||
// contact reads as its registration here too. Same helper the
|
||||
// Context panel's nearest list uses.
|
||||
title: this.services.formatAwarenessLabel(item),
|
||||
detail: t('{aircraftClass} · {distance}', {
|
||||
aircraftClass:
|
||||
cohort.id === 'military'
|
||||
? t('MILITARY FLIGHT')
|
||||
: t('COMMERCIAL FLIGHT'),
|
||||
distance: Number.isFinite(item.distanceM)
|
||||
? formatCockpitDistance(item.distanceM)
|
||||
: t('DISTANCE UNKNOWN'),
|
||||
}),
|
||||
target: { layerId: cohort.id, id: String(id) },
|
||||
distanceM: item.distanceM ?? Infinity,
|
||||
});
|
||||
}
|
||||
}
|
||||
contacts.sort((a, b) => a.distanceM - b.distanceM);
|
||||
const nextItems = contacts.slice(0, 5).map((item) => ({
|
||||
...item,
|
||||
timestamp:
|
||||
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
|
||||
}));
|
||||
if (unknownCount) {
|
||||
const sources = snapshot.cohorts
|
||||
.filter((cohort) => cohort.count === null)
|
||||
.map((cohort) => cohort.source)
|
||||
.join(' · ');
|
||||
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
|
||||
key: 'input-status',
|
||||
tone: 'warning',
|
||||
title: tn(
|
||||
unknownCount,
|
||||
'{count} INPUT UNKNOWN',
|
||||
'{count} INPUTS UNKNOWN',
|
||||
),
|
||||
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
|
||||
target: null,
|
||||
timestamp:
|
||||
previous.get('input-status')?.timestamp ||
|
||||
snapshot.evaluatedAt ||
|
||||
Date.now(),
|
||||
});
|
||||
}
|
||||
this.signalItems = nextItems;
|
||||
this.signalSignatures.clear();
|
||||
this.publishCockpitSignals();
|
||||
}
|
||||
|
||||
export function handleSignalClick(event) {
|
||||
if (this.destroyed) return;
|
||||
const target = event.target.closest(
|
||||
'button[data-signal-layer][data-signal-id]',
|
||||
);
|
||||
if (!target) return;
|
||||
event.preventDefault();
|
||||
this.services.militaryAwarenessLayer.focusTarget?.(
|
||||
target.dataset.signalLayer,
|
||||
target.dataset.signalId,
|
||||
{ origin: 'user' },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
formatHeadingReadout,
|
||||
normalizeHeading,
|
||||
resolveTrackedAircraftInfo,
|
||||
} from '../cockpitMath.js';
|
||||
|
||||
export function readAircraftInfo() {
|
||||
// In cockpit mode the controller takes the entity off `viewer.trackedEntity`
|
||||
// (see update()), so the cockpit's own handle is the tracked identity there.
|
||||
const trackedEntity = this.viewer?.trackedEntity || this.trackedEntity;
|
||||
return resolveTrackedAircraftInfo({
|
||||
civilian: this.services.flightsLayer.getTrackedInfo?.() || null,
|
||||
military: this.services.militaryFlightsLayer.getTrackedInfo?.() || null,
|
||||
trackedId: trackedEntity?.gevTrackedId || '',
|
||||
});
|
||||
}
|
||||
|
||||
export function dispatchCockpitModeChanged(active, info = null) {
|
||||
const subjectId = active
|
||||
? String(info?.icao24 || '')
|
||||
.trim()
|
||||
.toLowerCase() || null
|
||||
: null;
|
||||
const layerId =
|
||||
active && ['flights', 'military'].includes(info?.layerId)
|
||||
? info.layerId
|
||||
: null;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:cockpit-mode-changed', {
|
||||
detail: { active: active === true, subjectId, layerId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function toggleTrackedTr3b() {
|
||||
if (this.destroyed) return false;
|
||||
const info = this.readAircraftInfo();
|
||||
const icao24 = String(info?.icao24 || '').trim();
|
||||
if (!icao24) return false;
|
||||
this.services.toggleTr3b(icao24);
|
||||
const layer =
|
||||
info.layerId === 'military'
|
||||
? this.services.militaryFlightsLayer
|
||||
: this.services.flightsLayer;
|
||||
layer.refreshTr3b?.(icao24);
|
||||
this._tr3bSignature = null; // force the chip to repaint on the next sync
|
||||
this.syncTr3bToggle(info);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish which Cockpit surfaces are shown: the entry button, the TR-3B chip,
|
||||
* the HUD with its exit and reset buttons and briefing card, and the Contact
|
||||
* card. The views bind these; nothing else writes their visibility.
|
||||
*/
|
||||
export function publishPresenceView() {
|
||||
const presence = this.presence;
|
||||
this.publishPresence({
|
||||
...presence,
|
||||
tr3b: presence.tr3b && { ...presence.tr3b },
|
||||
});
|
||||
}
|
||||
|
||||
export function syncTr3bToggle(info) {
|
||||
const icao24 = String(info?.icao24 || '').trim();
|
||||
const converted = !!icao24 && this.services.isTr3b(icao24);
|
||||
const signature = icao24 ? `${icao24}:${converted ? 1 : 0}` : '';
|
||||
if (this._tr3bSignature === signature) return;
|
||||
this._tr3bSignature = signature;
|
||||
this.presence.tr3b = icao24 ? { converted } : null;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
export function syncEntry() {
|
||||
if (this.destroyed) return false;
|
||||
if (this.active) return;
|
||||
const info = this.readAircraftInfo();
|
||||
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
|
||||
this.syncTr3bToggle(trackedContact ? info : null);
|
||||
const available = !!(this.isEntryAllowed() && trackedContact);
|
||||
// Change-only publishes: this runs on a preUpdate cadence, and a fresh
|
||||
// presence every frame would re-render the views even when nothing
|
||||
// changed. (perf item 9)
|
||||
if (this.presence.entryAvailable === available) return;
|
||||
this.presence.entryAvailable = available;
|
||||
this.publishPresenceView();
|
||||
}
|
||||
|
||||
export function navigateContext(direction, options = {}) {
|
||||
if (this.destroyed) return false;
|
||||
const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
|
||||
const wasActive = this.active;
|
||||
if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500;
|
||||
const navigationOptions = wasActive
|
||||
? { ...options, aircraftOnly: true }
|
||||
: options;
|
||||
const changed = Boolean(
|
||||
this.services.militaryAwarenessLayer?.[method]?.(navigationOptions),
|
||||
);
|
||||
if (!changed) {
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
return false;
|
||||
}
|
||||
if (wasActive) this._adoptTrackedEntity(performance.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
|
||||
const nextEntity = this.viewer.trackedEntity;
|
||||
if (
|
||||
!this.active ||
|
||||
!nextEntity?.position ||
|
||||
nextEntity === this.trackedEntity
|
||||
)
|
||||
return false;
|
||||
const info = suppliedInfo || this.readAircraftInfo();
|
||||
if (!info) return false;
|
||||
if (this.trackedEntity && this.viewer.entities.contains(this.trackedEntity)) {
|
||||
this.trackedEntity.show = this.trackedEntityWasShown;
|
||||
}
|
||||
this.trackedEntity = nextEntity;
|
||||
this.trackedEntityWasShown = nextEntity.show;
|
||||
nextEntity.show = false;
|
||||
this.viewer.trackedEntity = undefined;
|
||||
this.cockpitAnchorValid = false;
|
||||
this.heading = normalizeHeading(info.track ?? 0);
|
||||
this.lastFrameMs = nowMs;
|
||||
this.lastHudUpdateMs = 0;
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function enter() {
|
||||
if (this.destroyed) return false;
|
||||
if (this.active) return false;
|
||||
if (!this.isEntryAllowed()) return false;
|
||||
const info = this.readAircraftInfo();
|
||||
const entity = this.viewer.trackedEntity;
|
||||
if (!info || !entity?.position) return false;
|
||||
this.entryFocusOrigin =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
// Retire deferred navigation before cancelFlight can run its callbacks.
|
||||
this.onCameraTakeover?.();
|
||||
this.viewer.camera.cancelFlight();
|
||||
this.trackedEntity = entity;
|
||||
this.trackedEntityWasShown = entity.show;
|
||||
entity.show = false;
|
||||
this.heading = normalizeHeading(info.track ?? 0);
|
||||
this.lastFrameMs = performance.now();
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.lastHudUpdateMs = 0;
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.lastGroundProbeMs = 0;
|
||||
this.surfaceWaitStartedMs = performance.now();
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
this.cockpitAnchorValid = false;
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.active = true;
|
||||
// Cockpit animates the camera from preUpdate every frame — preUpdate only
|
||||
// runs on rendered frames, so idle mode would freeze the cockpit solid.
|
||||
// (perf wave 2)
|
||||
this.services.holdContinuousRender('cockpit');
|
||||
this.viewer.trackedEntity = undefined;
|
||||
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
|
||||
document.body.classList.add('cockpit-mode');
|
||||
// Activation hides the entry and TR-3B chip; clearing the chip's signature
|
||||
// lets the exit-path syncEntry publish it again.
|
||||
this._tr3bSignature = null;
|
||||
Object.assign(this.presence, {
|
||||
active: true,
|
||||
entryAvailable: false,
|
||||
tr3b: null,
|
||||
});
|
||||
this.publishPresenceView();
|
||||
this.signalItems = [];
|
||||
this.signalSignatures.clear();
|
||||
this.showBriefPage(0);
|
||||
this.startBriefRotation();
|
||||
const trackLabel =
|
||||
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
|
||||
const trackHeading = formatHeadingReadout(info.track ?? 0);
|
||||
this.pushCockpitSignal(
|
||||
'track',
|
||||
'track',
|
||||
t('TRACK ACQUIRED'),
|
||||
t('{label} · COURSE {heading}°', {
|
||||
label: trackLabel,
|
||||
heading: trackHeading,
|
||||
}),
|
||||
);
|
||||
this.updateHud(info, performance.now(), true);
|
||||
this.setVisionMode(this.getInitialVisionMode());
|
||||
this.scheduleContextLayout();
|
||||
// The exit button shows when Vue flushes the presence; focus it after.
|
||||
Promise.resolve().then(() => {
|
||||
if (this.active && !this.destroyed)
|
||||
this.mapViewButton?.focus({ preventScroll: true });
|
||||
});
|
||||
this.onEntered?.();
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function exit({ restoreTracking = true } = {}) {
|
||||
if (!this.active) return false;
|
||||
const entity = this.trackedEntity;
|
||||
this.active = false;
|
||||
this.services.releaseContinuousRender('cockpit');
|
||||
this.trackedEntity = null;
|
||||
this.heading = null;
|
||||
this.cockpitAnchorValid = false;
|
||||
this.surfaceWaitStartedMs = 0;
|
||||
this.surfaceAcquiring = false;
|
||||
this.surfaceFallback = false;
|
||||
this.lastHudUpdateMs = 0;
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.stopBriefRotation();
|
||||
this.regionalBriefAbort?.abort();
|
||||
this.regionalBriefAbort = null;
|
||||
this.regionalBriefRequestToken += 1;
|
||||
this.regionalBriefSubjectId = null;
|
||||
document.body.classList.remove('cockpit-mode');
|
||||
this.onExited?.();
|
||||
this.publishUtilityLayout(null);
|
||||
this.presence.active = false;
|
||||
this.publishPresenceView();
|
||||
this.routeView.visible = false;
|
||||
this.clearPredictiveRoute();
|
||||
this.setVisionMode('optical');
|
||||
this.viewer.scene.screenSpaceCameraController.enableInputs = true;
|
||||
if (entity && this.viewer.entities.contains(entity))
|
||||
entity.show = this.trackedEntityWasShown;
|
||||
this.trackedEntityWasShown = true;
|
||||
this.dispatchCockpitModeChanged(false);
|
||||
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
|
||||
this.viewer.trackedEntity = entity;
|
||||
this.restoreTrackingFrame(entity);
|
||||
}
|
||||
this.syncEntry();
|
||||
const restoreTarget =
|
||||
this.entryFocusOrigin === this.entry
|
||||
? this.entry
|
||||
: this.entry || this.entryFocusOrigin;
|
||||
this.entryFocusOrigin = null;
|
||||
// The entry shows when Vue flushes the presence; read it after.
|
||||
Promise.resolve().then(() => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
!this.active &&
|
||||
restoreTarget?.isConnected &&
|
||||
!restoreTarget.hidden
|
||||
)
|
||||
restoreTarget.focus({ preventScroll: true });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// src/ui/cockpitUtilityControls.test.mjs — the Cockpit's Display and Radio
|
||||
// launchers and the compact Radio controls render what RadioControls
|
||||
// presents, over happy-dom with the real controls bound to them.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { onKeyDown as cockpitKeyDown } from './cockpitInput.js';
|
||||
import { RadioControls } from './radioControls.js';
|
||||
import { renderRadioState } from './radioPresentation.js';
|
||||
import { syncSignalLayout } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import { useRadioStore } from './stores/radio.js';
|
||||
import CockpitUtilityControls from './views/panels/CockpitUtilityControls.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const outside = document.body.appendChild(document.createElement('button'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitUtilityControls).use(pinia);
|
||||
app.mount(host);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const calls = [];
|
||||
let enabled = false;
|
||||
let toggles = 0;
|
||||
let finishToggle = null;
|
||||
const ui = { presentationActive: true, filter: 'all' };
|
||||
const actions = {
|
||||
isRegistered: () => true,
|
||||
isEnabled: () => enabled,
|
||||
runUserAction: (operation) => operation(),
|
||||
setEnabled: (next) => {
|
||||
toggles += 1;
|
||||
return new Promise((resolve) => {
|
||||
finishToggle = () => {
|
||||
enabled = next;
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
},
|
||||
setParams: (params) => {
|
||||
calls.push(['params', params]);
|
||||
return params.filter !== 'refused';
|
||||
},
|
||||
setPanelCollapsed() {},
|
||||
setSignalCollapsed: (collapsed) => calls.push(['signal', collapsed]),
|
||||
revealStyleParameters: () => calls.push(['reveal']),
|
||||
layoutCockpit() {},
|
||||
isCockpitActive: () => true,
|
||||
signalUserCollapsed: () => false,
|
||||
scheduleLayout() {},
|
||||
getLifecycle: () => null,
|
||||
preservePanelStateDuringClear: () => true,
|
||||
};
|
||||
const controls = new RadioControls({
|
||||
elements: {
|
||||
_radioPanel: document.createElement('section'),
|
||||
// A bare tuner slider: the panel's tuner has its own tests.
|
||||
_radioTunerSlider: Object.assign(document.createElement('input'), {
|
||||
type: 'range',
|
||||
}),
|
||||
_cockpitUtilityControls: $('cockpit-utility-controls'),
|
||||
_cockpitDisplayToggleBtn: $('cockpit-display-toggle-btn'),
|
||||
_cockpitRadioToggleBtn: $('cockpit-radio-toggle-btn'),
|
||||
_cockpitRadioEnableBtn: $('cockpit-radio-enable-btn'),
|
||||
},
|
||||
radio: {
|
||||
subscribe: () => () => {},
|
||||
endTuning() {},
|
||||
getTunerStations: () => [],
|
||||
getUIState: () => ui,
|
||||
cycleStation: (direction, { rotate }) =>
|
||||
calls.push(['cycle', direction, rotate]),
|
||||
togglePlayback: () => calls.push(['playback']),
|
||||
},
|
||||
actions,
|
||||
canvas: document.createElement('canvas'),
|
||||
store: useRadioStore(pinia),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.destroy();
|
||||
app.unmount();
|
||||
});
|
||||
const render = async (state) => {
|
||||
renderRadioState.call(controls, {
|
||||
enabled: true,
|
||||
categories: [{ id: 'all', label: 'All', count: 3, color: '' }],
|
||||
filter: 'all',
|
||||
filteredCount: 3,
|
||||
stationCount: 3,
|
||||
volume: 0.8,
|
||||
audioState: 'stopped',
|
||||
...state,
|
||||
});
|
||||
await nextTick();
|
||||
};
|
||||
return {
|
||||
$,
|
||||
controls,
|
||||
calls,
|
||||
ui,
|
||||
render,
|
||||
outside,
|
||||
finishToggle: () => finishToggle(),
|
||||
toggles: () => toggles,
|
||||
};
|
||||
}
|
||||
|
||||
const control = (button) => button.closest('.cockpit-utility-control');
|
||||
|
||||
test('before RadioControls presents, both popovers are closed and the controls start idle', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
for (const kind of ['display', 'radio']) {
|
||||
const toggle = $(`cockpit-${kind}-toggle-btn`);
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(toggle.textContent, '◀');
|
||||
assert.equal($(`cockpit-${kind}-panel`).hidden, true);
|
||||
assert.equal(control(toggle).classList.contains('is-expanded'), false);
|
||||
}
|
||||
assert.equal(
|
||||
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
|
||||
'Expand Cockpit display options',
|
||||
);
|
||||
assert.equal(
|
||||
$('cockpit-radio-toggle-btn').title,
|
||||
'Expand Cockpit Radio controls',
|
||||
);
|
||||
assert.equal($('cockpit-radio-station').textContent, 'READY');
|
||||
const enable = $('cockpit-radio-enable-btn');
|
||||
assert.equal(enable.textContent, 'ENABLE');
|
||||
assert.equal(enable.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(enable.hasAttribute('aria-busy'), false);
|
||||
assert.equal($('cockpit-radio-play-btn').disabled, true);
|
||||
assert.equal($('cockpit-radio-play-btn').textContent, '▶');
|
||||
assert.equal($('cockpit-radio-volume').value, '80');
|
||||
assert.equal($('cockpit-radio-volume').disabled, true);
|
||||
assert.equal($('cockpit-radio-volume-value').textContent, '80%');
|
||||
assert.equal(
|
||||
document.querySelectorAll('[data-cockpit-display-slot]').length,
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
test('a launcher opens its popover, closes the other, and Escape returns focus', async (t) => {
|
||||
const { $, controls, calls } = fixture(t);
|
||||
// The application shell takes the Display launcher's clicks.
|
||||
controls._setCockpitDisclosure('display', true);
|
||||
await nextTick();
|
||||
assert.equal(controls._cockpitDisclosureOpen('display'), true);
|
||||
assert.equal($('cockpit-display-panel').hidden, false);
|
||||
assert.equal($('cockpit-display-toggle-btn').textContent, '▶');
|
||||
assert.equal(
|
||||
$('cockpit-display-toggle-btn').getAttribute('aria-label'),
|
||||
'Collapse Cockpit display options',
|
||||
);
|
||||
assert.equal(
|
||||
control($('cockpit-display-toggle-btn')).classList.contains('is-expanded'),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(calls.slice(0, 2), [['signal', true], ['reveal']]);
|
||||
$('cockpit-radio-toggle-btn').click();
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-display-panel').hidden, true);
|
||||
assert.equal($('cockpit-radio-panel').hidden, false);
|
||||
assert.equal(
|
||||
$('cockpit-radio-toggle-btn').getAttribute('aria-expanded'),
|
||||
'true',
|
||||
);
|
||||
$('cockpit-radio-play-btn').dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-panel').hidden, true);
|
||||
assert.equal(document.activeElement === $('cockpit-radio-toggle-btn'), true);
|
||||
assert.deepEqual(calls.at(-1), ['signal', false]);
|
||||
});
|
||||
|
||||
test('a press outside the Cockpit controls closes the open popover', async (t) => {
|
||||
const { $, controls, outside } = fixture(t);
|
||||
controls._setCockpitDisclosure('radio', true);
|
||||
await nextTick();
|
||||
$('cockpit-radio-volume').dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-panel').hidden, false);
|
||||
outside.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-panel').hidden, true);
|
||||
assert.equal(controls._cockpitDisclosureOpen('radio'), false);
|
||||
});
|
||||
|
||||
test('the compact controls render the presented Radio state', async (t) => {
|
||||
const { $, render } = fixture(t);
|
||||
await render({
|
||||
selected: { id: 's1', name: 'KUTX' },
|
||||
audioState: 'playing',
|
||||
volume: 0.35,
|
||||
});
|
||||
assert.equal($('cockpit-radio-station').textContent, 'KUTX');
|
||||
const enable = $('cockpit-radio-enable-btn');
|
||||
assert.equal(enable.textContent, 'DISABLE');
|
||||
assert.equal(enable.classList.contains('active'), true);
|
||||
assert.equal(enable.classList.contains('panel-layer-toggle'), true);
|
||||
assert.equal(enable.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(enable.getAttribute('aria-label'), 'Disable Radio');
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(enable.disabled, false);
|
||||
const play = $('cockpit-radio-play-btn');
|
||||
assert.equal(play.disabled, false);
|
||||
assert.equal(play.textContent, 'Ⅱ');
|
||||
assert.equal(play.classList.contains('active'), true);
|
||||
assert.equal(play.getAttribute('aria-label'), 'Pause selected radio station');
|
||||
assert.equal(play.title, 'Pause');
|
||||
assert.equal($('cockpit-radio-prev-btn').disabled, false);
|
||||
assert.equal($('cockpit-radio-volume').value, '35');
|
||||
assert.equal($('cockpit-radio-volume').disabled, false);
|
||||
assert.equal($('cockpit-radio-volume-value').textContent, '35%');
|
||||
await render({ enabled: false, selected: null, loading: true });
|
||||
assert.equal($('cockpit-radio-station').textContent, 'SYNCING');
|
||||
assert.equal(enable.textContent, 'ENABLE');
|
||||
assert.equal(enable.classList.contains('active'), false);
|
||||
assert.equal(play.disabled, true);
|
||||
assert.equal(play.textContent, '▶');
|
||||
assert.equal($('cockpit-radio-volume').disabled, true);
|
||||
});
|
||||
|
||||
test('dragging the volume shows the value at once and a render leaves the held slider alone', async (t) => {
|
||||
const { $, calls, render } = fixture(t);
|
||||
await render({ volume: 0.8 });
|
||||
const volume = $('cockpit-radio-volume');
|
||||
volume.focus();
|
||||
volume.value = '42';
|
||||
volume.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal($('cockpit-radio-volume-value').textContent, '42%');
|
||||
assert.deepEqual(calls.at(-1), ['params', { volume: 0.42 }]);
|
||||
// A render reporting an older volume while the slider is held.
|
||||
await render({ volume: 0.8 });
|
||||
assert.equal(volume.value, '42');
|
||||
});
|
||||
|
||||
test('the compact transport steps stations without rotating the camera', (t) => {
|
||||
const { $, calls } = fixture(t);
|
||||
for (const id of [
|
||||
'cockpit-radio-prev-btn',
|
||||
'cockpit-radio-play-btn',
|
||||
'cockpit-radio-next-btn',
|
||||
]) {
|
||||
$(id).disabled = false;
|
||||
$(id).click();
|
||||
}
|
||||
assert.deepEqual(calls, [
|
||||
['cycle', -1, false],
|
||||
['playback'],
|
||||
['cycle', 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the station filter answers with the tag the directory keeps', (t) => {
|
||||
const { controls, calls, ui } = fixture(t);
|
||||
assert.equal(controls.store.act('setRadioFilter', 'jazz'), 'jazz');
|
||||
assert.equal(controls.store.act('setRadioFilter', 'refused'), 'all');
|
||||
ui.presentationActive = false;
|
||||
assert.equal(controls.store.act('setRadioFilter', 'rock'), 'all');
|
||||
assert.deepEqual(calls, [
|
||||
['params', { filter: 'jazz' }],
|
||||
['params', { filter: 'refused' }],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a destroyed RadioControls answers no controls', (t) => {
|
||||
const { controls, calls } = fixture(t);
|
||||
controls.destroy();
|
||||
assert.equal(controls.store.act('setRadioFilter', 'jazz'), undefined);
|
||||
controls.store.act('cycleRadio', 1);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('the Cockpit Enable button is marked busy while its own toggle runs', async (t) => {
|
||||
const { $, render, finishToggle, toggles } = fixture(t);
|
||||
await render({ enabled: false });
|
||||
const enable = $('cockpit-radio-enable-btn');
|
||||
// A double press lands before the busy state renders; only one toggle runs.
|
||||
enable.click();
|
||||
enable.click();
|
||||
assert.equal(toggles(), 1);
|
||||
await nextTick();
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(enable.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(enable.disabled, false);
|
||||
// A second press while busy starts nothing.
|
||||
enable.click();
|
||||
assert.equal(toggles(), 1);
|
||||
finishToggle();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
assert.equal(enable.getAttribute('aria-busy'), 'false');
|
||||
});
|
||||
|
||||
test('the launchers read in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, controls } = fixture(t);
|
||||
const before = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
|
||||
controls._setCockpitDisclosure('radio', true);
|
||||
await nextTick();
|
||||
const after = $('cockpit-radio-toggle-btn').getAttribute('aria-label');
|
||||
assert.notEqual(before, 'Expand Cockpit Radio controls');
|
||||
assert.notEqual(after, before);
|
||||
assert.notEqual(after, 'Collapse Cockpit Radio controls');
|
||||
});
|
||||
|
||||
test('Cockpit Escape defers to a popover the moment its disclosure opens', async (t) => {
|
||||
const { controls } = fixture(t);
|
||||
let exits = 0;
|
||||
const cockpit = {
|
||||
active: true,
|
||||
isUtilityDisclosureOpen: () =>
|
||||
controls._cockpitDisclosureOpen('display') ||
|
||||
controls._cockpitDisclosureOpen('radio'),
|
||||
exit: () => (exits += 1),
|
||||
};
|
||||
const escape = () =>
|
||||
cockpitKeyDown.call(
|
||||
cockpit,
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
// Dispatched in the same task, before Vue renders the open popover.
|
||||
controls._setCockpitDisclosure('radio', true);
|
||||
escape();
|
||||
assert.equal(exits, 0);
|
||||
controls._setCockpitDisclosure('radio', false);
|
||||
escape();
|
||||
assert.equal(exits, 1);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
test('the layout pass’s measured fit hides the collapsed control and marks the strip', async () => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitUtilityControls).use(pinia);
|
||||
app.mount(host);
|
||||
const radio = useRadioStore(pinia);
|
||||
const cockpit = useCockpitStore(pinia);
|
||||
const strip = document.getElementById('cockpit-utility-controls');
|
||||
const [display, radioControl] = strip.querySelectorAll(
|
||||
'.cockpit-utility-control',
|
||||
);
|
||||
const layout = (fit) => ({
|
||||
top: '96.0px',
|
||||
maxHeight: '300.00px',
|
||||
expandedMaxHeight: '300.00px',
|
||||
primaryOnly: false,
|
||||
hideCollapsed: false,
|
||||
...fit,
|
||||
});
|
||||
// Until the layout pass runs, neither control carries aria-hidden.
|
||||
assert.equal(display.hasAttribute('aria-hidden'), false);
|
||||
radio.publishUtility({ display: false, radio: true });
|
||||
cockpit.publishUtilityLayout(
|
||||
layout({ primaryOnly: true, hideCollapsed: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), true);
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
|
||||
// With nothing expanded there is no collapsed sibling to hide.
|
||||
radio.publishUtility({ display: false, radio: false });
|
||||
await nextTick();
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'false');
|
||||
cockpit.publishUtilityLayout(layout());
|
||||
await nextTick();
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), false);
|
||||
app.unmount();
|
||||
});
|
||||
|
||||
test('the real layout pass drives the mounted controls: compact hides the collapsed sibling, Cyber shows both', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(CockpitUtilityControls).use(pinia);
|
||||
app.mount(host);
|
||||
const radio = useRadioStore(pinia);
|
||||
const cockpit = useCockpitStore(pinia);
|
||||
const signalStream = document.body.appendChild(
|
||||
document.createElement('aside'),
|
||||
);
|
||||
const owner = {
|
||||
signalStream,
|
||||
signalCollapsed: false,
|
||||
contextCollapsed: false,
|
||||
context: null,
|
||||
publishUtilityLayout: (layout) => cockpit.publishUtilityLayout(layout),
|
||||
syncContextLayout() {},
|
||||
};
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
let compact = true;
|
||||
window.matchMedia = (query) => ({ matches: compact, media: query });
|
||||
t.after(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
delete document.documentElement.dataset.uiTheme;
|
||||
document.body.style.removeProperty('opacity');
|
||||
document.documentElement.style.removeProperty('opacity');
|
||||
app.unmount();
|
||||
});
|
||||
const strip = document.getElementById('cockpit-utility-controls');
|
||||
const [display, radioControl] = strip.querySelectorAll(
|
||||
'.cockpit-utility-control',
|
||||
);
|
||||
radio.publishUtility({ display: false, radio: true });
|
||||
await nextTick();
|
||||
syncSignalLayout.call(owner);
|
||||
await nextTick();
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(cockpit.utilityLayout.top, null);
|
||||
// Compact hides the sibling without marking the strip primary-only.
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), false);
|
||||
// Cyber aligns the strip with the Data panel and keeps both controls.
|
||||
compact = false;
|
||||
document.documentElement.dataset.uiTheme = 'cyber';
|
||||
const stack = document.body.appendChild(document.createElement('div'));
|
||||
stack.id = 'left-panel-stack';
|
||||
const dataPanel = stack.appendChild(document.createElement('section'));
|
||||
dataPanel.id = 'data-panel';
|
||||
for (const node of [
|
||||
dataPanel,
|
||||
stack,
|
||||
document.body,
|
||||
document.documentElement,
|
||||
])
|
||||
node.style.opacity = '1';
|
||||
dataPanel.getBoundingClientRect = () => ({
|
||||
top: 120,
|
||||
bottom: 400,
|
||||
left: 0,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 280,
|
||||
});
|
||||
syncSignalLayout.call(owner);
|
||||
await nextTick();
|
||||
assert.equal(display.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(radioControl.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(cockpit.utilityLayout.top, '120.00px');
|
||||
assert.equal(strip.classList.contains('layout-primary-only'), false);
|
||||
});
|
||||
@@ -0,0 +1,488 @@
|
||||
// src/ui/commandDock.test.mjs — the command dock and the scene chrome render
|
||||
// what VisualSettings publishes, over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { useLocationStore } from './stores/location.js';
|
||||
import { LocationControls } from './locationControls.js';
|
||||
import { MOBILE_TABS, initMobileShell } from './mobileShell.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import SceneChromeView from './views/SceneChromeView.vue';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp({
|
||||
render: () => [h(SceneChromeView), h(CommandDockView)],
|
||||
}).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useDisplayStore(pinia);
|
||||
// VisualSettings publishes through its store; the method needs no more.
|
||||
const settings = Object.assign(Object.create(VisualSettings.prototype), {
|
||||
store,
|
||||
activeStyle: 'normal',
|
||||
});
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const active = () =>
|
||||
[...document.querySelectorAll('.style-btn.active')].map(
|
||||
(button) => button.dataset.style,
|
||||
);
|
||||
return { $, settings, active, pinia };
|
||||
}
|
||||
|
||||
test('before VisualSettings publishes, the dock shows NORMAL', (t) => {
|
||||
const { $, active } = fixture(t);
|
||||
assert.deepEqual(active(), ['normal']);
|
||||
assert.equal($('#style-mini-value').textContent, 'NORMAL');
|
||||
assert.equal($('#active-style-name').textContent, 'NORMAL');
|
||||
});
|
||||
|
||||
test('the active style marks its button and labels the dock and the corner', async (t) => {
|
||||
const { $, settings, active } = fixture(t);
|
||||
settings._publishActiveStyle('thermal');
|
||||
await nextTick();
|
||||
assert.deepEqual(active(), ['thermal']);
|
||||
assert.equal($('#style-mini-value').textContent, 'FLIR');
|
||||
assert.equal($('#active-style-name').textContent, 'FLIR');
|
||||
settings.activeStyle = 'unlisted';
|
||||
settings._publishActiveStyle();
|
||||
await nextTick();
|
||||
assert.deepEqual(active(), []);
|
||||
assert.equal($('#style-mini-value').textContent, 'UNLISTED');
|
||||
settings._publishActiveStyle('normal');
|
||||
await nextTick();
|
||||
assert.deepEqual(active(), ['normal']);
|
||||
});
|
||||
|
||||
test('the style buttons keep classes other code sets', async (t) => {
|
||||
const { $, settings } = fixture(t);
|
||||
const noir = $('.style-btn[data-style="noir"]');
|
||||
noir.classList.add('voice-highlight');
|
||||
settings._publishActiveStyle('noir');
|
||||
await nextTick();
|
||||
assert.equal(noir.classList.contains('voice-highlight'), true);
|
||||
assert.equal(noir.classList.contains('active'), true);
|
||||
});
|
||||
|
||||
test('the dock and the corner label the style in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, settings } = fixture(t);
|
||||
assert.equal($('#style-mini-value').textContent, 'ОБЫЧНЫЙ');
|
||||
assert.equal($('#active-style-name').textContent, 'ОБЫЧНЫЙ');
|
||||
settings._publishActiveStyle('noir');
|
||||
await nextTick();
|
||||
assert.equal($('#style-mini-value').textContent, 'НУАР');
|
||||
assert.equal($('#active-style-name').textContent, 'НУАР');
|
||||
});
|
||||
|
||||
const chipView = (activeId, { warn = false, text = 'OSM' } = {}) => ({
|
||||
chips: [
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing',
|
||||
available: false,
|
||||
active: false,
|
||||
requirement: 'ION',
|
||||
title: 'Needs a key',
|
||||
ariaLabel: 'Bing unavailable: Needs a key',
|
||||
},
|
||||
...['esri-imagery', 'osm'].map((id) => ({
|
||||
id,
|
||||
label: id.toUpperCase(),
|
||||
available: true,
|
||||
active: id === activeId,
|
||||
requirement: '',
|
||||
title: id.toUpperCase(),
|
||||
ariaLabel: null,
|
||||
})),
|
||||
],
|
||||
status: { text, warn },
|
||||
});
|
||||
|
||||
test('the map source row renders the published chips and selects through the controls', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#map-stack-status').textContent, '3D');
|
||||
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0);
|
||||
const store = useDisplayStore(pinia);
|
||||
const selected = [];
|
||||
store.connectMapSource({
|
||||
select: async (id) => selected.push(id),
|
||||
});
|
||||
store.publishMapSource(chipView('osm'));
|
||||
await nextTick();
|
||||
const chips = [
|
||||
...document.querySelectorAll('#map-stack-chips .map-stack-chip'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
chips.map((chip) => chip.dataset.stackId),
|
||||
['bing-aerial', 'esri-imagery', 'osm'],
|
||||
);
|
||||
const [bing, esri, osm] = chips;
|
||||
assert.equal(bing.classList.contains('unavailable'), true);
|
||||
assert.equal(
|
||||
bing.disabled,
|
||||
false,
|
||||
'unavailable chips stay keyboard-reachable',
|
||||
);
|
||||
assert.equal(bing.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(
|
||||
bing.getAttribute('aria-label'),
|
||||
'Bing unavailable: Needs a key',
|
||||
);
|
||||
assert.equal(bing.title, 'Needs a key');
|
||||
assert.equal(bing.querySelector('.map-stack-chip-req').textContent, 'ION');
|
||||
assert.equal(osm.classList.contains('active'), true);
|
||||
assert.equal(osm.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(osm.hasAttribute('aria-label'), false);
|
||||
assert.equal(esri.querySelector('.map-stack-chip-req'), null);
|
||||
bing.click();
|
||||
esri.click();
|
||||
assert.deepEqual(selected, ['esri-imagery']);
|
||||
esri.classList.add('voice-highlight');
|
||||
store.publishMapSource(chipView('esri-imagery', { text: '...', warn: true }));
|
||||
await nextTick();
|
||||
assert.equal(esri.classList.contains('active'), true);
|
||||
assert.equal(esri.classList.contains('voice-highlight'), true);
|
||||
assert.equal(osm.classList.contains('active'), false);
|
||||
assert.equal($('#map-stack-status').textContent, '...');
|
||||
assert.equal($('#map-stack-status').classList.contains('warn'), true);
|
||||
store.publishMapSource(null);
|
||||
await nextTick();
|
||||
assert.equal($('#map-stack-status').textContent, '3D');
|
||||
assert.equal($('#map-stack-status').classList.contains('warn'), false);
|
||||
assert.equal(document.querySelectorAll('.map-stack-chip').length, 0);
|
||||
});
|
||||
|
||||
test('the ORBIT indicator sits on the body while offered and lights while orbiting', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#orbit-indicator'), null);
|
||||
const store = useLocationStore(pinia);
|
||||
const controls = new LocationControls({
|
||||
elements: {
|
||||
search: $('#location-search'),
|
||||
},
|
||||
cities: {},
|
||||
getExpandedCity: () => null,
|
||||
onCity() {},
|
||||
onPoi() {},
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame() {},
|
||||
cancelFrame() {},
|
||||
});
|
||||
controls.showOrbit(false);
|
||||
await nextTick();
|
||||
const indicator = $('#orbit-indicator');
|
||||
assert.equal(indicator.parentElement, document.body);
|
||||
assert.equal(indicator.textContent, '↻ ORBIT');
|
||||
assert.equal(indicator.querySelector('.orbit-icon').textContent, '↻');
|
||||
assert.equal(indicator.classList.contains('active'), false);
|
||||
controls.showOrbit(true);
|
||||
await nextTick();
|
||||
assert.equal(indicator.classList.contains('active'), true);
|
||||
controls.showOrbit(false);
|
||||
await nextTick();
|
||||
assert.equal(indicator.classList.contains('active'), false);
|
||||
assert.ok($('#orbit-indicator') === indicator, 'stopping keeps it in place');
|
||||
controls.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#orbit-indicator'), null);
|
||||
});
|
||||
|
||||
test('the location tray renders what LocationControls publishes', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#location-mini-city').textContent, '📍 Location: --');
|
||||
assert.equal($('#location-mini-poi').textContent, 'Landmark: --');
|
||||
const store = useLocationStore(pinia);
|
||||
const calls = [];
|
||||
const frames = [];
|
||||
const controls = new LocationControls({
|
||||
elements: {
|
||||
search: $('#location-search'),
|
||||
},
|
||||
cities: {
|
||||
paris: { name: 'Paris', pois: [{ name: 'Louvre' }, { name: 'Eiffel' }] },
|
||||
rome: { name: 'Rome', pois: [{ name: 'Colosseum' }] },
|
||||
},
|
||||
getExpandedCity: () => null,
|
||||
onCity: (id) => calls.push(['city', id]),
|
||||
onPoi: (id, index) => calls.push(['poi', id, index]),
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame: (fn) => frames.push(fn),
|
||||
cancelFrame() {},
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
store.connect(controls);
|
||||
await nextTick();
|
||||
const pills = [
|
||||
...document.querySelectorAll('#location-pills .location-pill'),
|
||||
];
|
||||
assert.deepEqual(
|
||||
pills.map((pill) => `${pill.dataset.locationId}:${pill.textContent}`),
|
||||
['paris:Paris', 'rome:Rome'],
|
||||
);
|
||||
pills[1].click();
|
||||
assert.deepEqual(calls, [['city', 'rome']]);
|
||||
pills[1].classList.add('voice-highlight');
|
||||
controls.highlightCity('rome');
|
||||
controls.showPois('paris');
|
||||
await nextTick();
|
||||
assert.equal(pills[1].classList.contains('active'), true);
|
||||
assert.equal(pills[1].classList.contains('voice-highlight'), true);
|
||||
const row = $('#poi-row');
|
||||
const pois = [...row.querySelectorAll('.poi-pill')];
|
||||
assert.deepEqual(
|
||||
pois.map((poi) => poi.textContent),
|
||||
['QLouvre', 'WEiffel'],
|
||||
);
|
||||
assert.equal(row.classList.contains('expanded'), false);
|
||||
frames.shift()();
|
||||
await nextTick();
|
||||
assert.equal(row.classList.contains('expanded'), true);
|
||||
assert.equal($('#location-bar-divider').classList.contains('visible'), true);
|
||||
controls.highlightPoi(1);
|
||||
await nextTick();
|
||||
assert.equal(pois[1].classList.contains('active'), true);
|
||||
pois[1].click();
|
||||
assert.deepEqual(calls.at(-1), ['poi', 'paris', 1]);
|
||||
// Switching cities while the row is open swaps its pills and keeps it open.
|
||||
controls.showPois('rome');
|
||||
await nextTick();
|
||||
assert.equal(row.classList.contains('expanded'), true);
|
||||
const romePois = [...row.querySelectorAll('.poi-pill')];
|
||||
assert.deepEqual(
|
||||
romePois.map((poi) => poi.textContent),
|
||||
['QColosseum'],
|
||||
);
|
||||
assert.equal(romePois[0].classList.contains('active'), false);
|
||||
romePois[0].click();
|
||||
assert.deepEqual(calls.at(-1), ['poi', 'rome', 0]);
|
||||
frames.shift()();
|
||||
controls.renderStatus({
|
||||
city: { name: 'Paris' },
|
||||
currentPoi: { name: 'Eiffel' },
|
||||
});
|
||||
controls.setSearchError('No match');
|
||||
await nextTick();
|
||||
assert.match($('#location-mini-city').textContent, /Paris/);
|
||||
assert.match($('#location-mini-poi').textContent, /Eiffel/);
|
||||
assert.equal($('#location-search-error').textContent, 'No match');
|
||||
const search = $('#location-search');
|
||||
assert.equal(search.getAttribute('aria-invalid'), 'true');
|
||||
assert.equal(
|
||||
search.getAttribute('aria-describedby'),
|
||||
'location-search-error',
|
||||
);
|
||||
controls.setSearchError(null);
|
||||
controls.hidePois();
|
||||
await nextTick();
|
||||
assert.equal(search.hasAttribute('aria-invalid'), false);
|
||||
assert.equal(search.hasAttribute('aria-describedby'), false);
|
||||
assert.equal($('#location-search-error').textContent, '');
|
||||
assert.equal(row.classList.contains('expanded'), false);
|
||||
assert.equal($('#location-bar-divider').classList.contains('visible'), false);
|
||||
});
|
||||
|
||||
// These compare nodes by identity in a boolean: a failing assertion on a
|
||||
// happy-dom node would try to print the whole document.
|
||||
test('the phone tab bar joins the end of the page while the shell publishes', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
assert.equal($('#mobile-tab-bar') === null, true);
|
||||
assert.equal($('#mobile-sheet-backdrop') === null, true);
|
||||
// The scene adds its credits after the views mount; the bar follows them.
|
||||
const credits = document.body.appendChild(document.createElement('div'));
|
||||
const store = useDisplayStore(pinia);
|
||||
store.publishPhoneShell({ enabled: true, sheet: null, tabs: MOBILE_TABS });
|
||||
await nextTick();
|
||||
const bar = $('#mobile-tab-bar');
|
||||
const backdrop = $('#mobile-sheet-backdrop');
|
||||
assert.equal(document.body.lastElementChild === bar, true);
|
||||
assert.equal(bar.previousElementSibling === backdrop, true);
|
||||
assert.equal(backdrop.previousElementSibling === credits, true);
|
||||
assert.equal(bar.getAttribute('aria-label'), 'Panels');
|
||||
assert.equal(bar.getAttribute('aria-hidden'), 'false');
|
||||
assert.equal(backdrop.hidden, true);
|
||||
const tabs = [...bar.querySelectorAll('.mobile-tab')];
|
||||
assert.deepEqual(
|
||||
tabs.map(
|
||||
(tab) =>
|
||||
`${tab.dataset.mobileSheet}:${tab.querySelector('.mobile-tab-icon').textContent}:${tab.querySelector('.mobile-tab-label').textContent}`,
|
||||
),
|
||||
[
|
||||
'layers:layers:Layers',
|
||||
'cameras:videocam:Cameras',
|
||||
'context:radar:Context',
|
||||
'search:search:Search',
|
||||
'more:more_horiz:More',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
tabs.map((tab) => tab.getAttribute('aria-expanded')),
|
||||
['false', 'false', 'false', 'false', 'false'],
|
||||
);
|
||||
store.publishPhoneShell({
|
||||
enabled: true,
|
||||
sheet: 'context',
|
||||
tabs: MOBILE_TABS,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(backdrop.hidden, false);
|
||||
assert.deepEqual(
|
||||
tabs.map((tab) => tab.getAttribute('aria-expanded')),
|
||||
['false', 'false', 'true', 'false', 'false'],
|
||||
);
|
||||
store.publishPhoneShell({ enabled: false, sheet: null, tabs: MOBILE_TABS });
|
||||
await nextTick();
|
||||
assert.equal(bar.getAttribute('aria-hidden'), 'true');
|
||||
assert.equal(backdrop.hidden, true);
|
||||
assert.deepEqual(
|
||||
tabs.map((tab) => tab.getAttribute('aria-expanded')),
|
||||
['false', 'false', 'false', 'false', 'false'],
|
||||
);
|
||||
store.publishPhoneShell(null);
|
||||
await nextTick();
|
||||
assert.equal($('#mobile-tab-bar') === null, true);
|
||||
assert.equal($('#mobile-sheet-backdrop') === null, true);
|
||||
});
|
||||
|
||||
test('the phone tabs are labelled in the UI language', async (t) => {
|
||||
t.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
const { $, pinia } = fixture(t);
|
||||
useDisplayStore(pinia).publishPhoneShell({
|
||||
enabled: true,
|
||||
sheet: null,
|
||||
tabs: MOBILE_TABS,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal($('#mobile-tab-bar').getAttribute('aria-label'), 'Панели');
|
||||
assert.equal(
|
||||
$('#mobile-tab-bar [data-mobile-sheet="layers"] .mobile-tab-label')
|
||||
.textContent,
|
||||
'Слои',
|
||||
);
|
||||
});
|
||||
|
||||
test('closing a sheet returns focus to its tab once the tab bar shows it closed', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
const store = useDisplayStore(pinia);
|
||||
const init = () =>
|
||||
initMobileShell({
|
||||
documentRef: document,
|
||||
matchMedia: () => ({ matches: true }),
|
||||
ResizeObserverImpl: null,
|
||||
requestFrame: () => 0,
|
||||
setPanelCollapsed() {},
|
||||
publish: (view) => store.publishPhoneShell(view),
|
||||
afterPublish: (callback) => {
|
||||
void nextTick(callback);
|
||||
},
|
||||
});
|
||||
let shell = init();
|
||||
t.after(() => shell.destroy());
|
||||
await nextTick();
|
||||
const tab = $('#mobile-tab-bar [data-mobile-sheet="search"]');
|
||||
tab.focus();
|
||||
tab.click();
|
||||
await nextTick();
|
||||
assert.equal(tab.getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(document.activeElement?.id, 'location-search');
|
||||
let expandedAtFocus = null;
|
||||
tab.addEventListener('focus', () => {
|
||||
expandedAtFocus = tab.getAttribute('aria-expanded');
|
||||
});
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(document.activeElement === tab, true);
|
||||
assert.equal(expandedAtFocus, 'false');
|
||||
assert.equal($('#mobile-sheet-backdrop').hidden, true);
|
||||
// Reopening before the closed tab bar renders keeps focus in the sheet.
|
||||
const layers = $('#mobile-tab-bar [data-mobile-sheet="layers"]');
|
||||
const inSheet = document.body.appendChild(document.createElement('button'));
|
||||
const escape = () =>
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
layers.focus();
|
||||
layers.click();
|
||||
inSheet.focus();
|
||||
escape();
|
||||
layers.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(document.body.dataset.mobileSheet, 'layers');
|
||||
assert.equal(document.activeElement === inSheet, true);
|
||||
// So does destroying the shell with a sheet open.
|
||||
escape();
|
||||
layers.focus();
|
||||
layers.click();
|
||||
inSheet.focus();
|
||||
shell.destroy();
|
||||
shell = init();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
assert.equal(document.activeElement === inSheet, true);
|
||||
// A destroyed shell takes its bar along; a new one brings it back.
|
||||
shell.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#mobile-tab-bar') === null, true);
|
||||
shell = init();
|
||||
await nextTick();
|
||||
assert.equal(document.querySelectorAll('#mobile-tab-bar').length, 1);
|
||||
$('#mobile-tab-bar [data-mobile-sheet="more"]').click();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
$('#mobile-tab-bar [data-mobile-sheet="more"]').getAttribute(
|
||||
'aria-expanded',
|
||||
),
|
||||
'true',
|
||||
);
|
||||
assert.equal($('#mobile-sheet-backdrop').hidden, false);
|
||||
});
|
||||
|
||||
test('the search toggle opens the field, and a lookup marks it', async (t) => {
|
||||
const { $, pinia } = fixture(t);
|
||||
const store = useLocationStore(pinia);
|
||||
const controls = new LocationControls({
|
||||
elements: { search: $('#location-search') },
|
||||
cities: {},
|
||||
getExpandedCity: () => null,
|
||||
onCity() {},
|
||||
onPoi() {},
|
||||
onSearch() {},
|
||||
onReset() {},
|
||||
publish: (view) => store.publish(view),
|
||||
requestFrame() {},
|
||||
cancelFrame() {},
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
store.connect(controls);
|
||||
await nextTick();
|
||||
const field = $('#location-search');
|
||||
assert.equal(field.classList.contains('expanded'), false);
|
||||
$('#search-toggle').click();
|
||||
await nextTick();
|
||||
assert.equal(field.classList.contains('expanded'), true);
|
||||
assert.equal(document.activeElement, field);
|
||||
controls.setSearching(true);
|
||||
await nextTick();
|
||||
assert.equal(field.classList.contains('searching'), true);
|
||||
controls.settleSearch();
|
||||
await nextTick();
|
||||
assert.equal(field.classList.contains('searching'), false);
|
||||
assert.equal(field.classList.contains('expanded'), false);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/** Compose UI controls with the application's existing engines and layer instances. */
|
||||
import { StyleManager as ApplicationShell } from './applicationShell.js';
|
||||
import { LocationSearch } from './location.js';
|
||||
import { CITY_POIS } from '../cityPois.js';
|
||||
import {
|
||||
GLOBE_VIEW,
|
||||
flyToGlobeView,
|
||||
flyToPresetLocation,
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
} from '../locations.js';
|
||||
import {
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
} from '../cameraVerbs.js';
|
||||
import { IntelHUD } from '../hud.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { OrbitController } from '../orbit.js';
|
||||
import {
|
||||
CelestialRing,
|
||||
getKeyholeFadeTuning,
|
||||
isCelestialRingStyleSupported,
|
||||
setKeyholeFadeTuning,
|
||||
} from '../celestialRing.js';
|
||||
import {
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
} from '../data/trackedReadout.js';
|
||||
import {
|
||||
activateWorldOverlayAction,
|
||||
destroyWorldOverlay,
|
||||
initWorldOverlay,
|
||||
subscribeWorldOverlayActions,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import {
|
||||
destroyDetection,
|
||||
initDetection,
|
||||
cycleMode as cycleDetectionMode,
|
||||
getDetectionDiagnostics as readDetectionDiagnostics,
|
||||
getDetectionTuning,
|
||||
getMode as getDetectionMode,
|
||||
setMode as setDetectionModeByLabel,
|
||||
suspendDetection,
|
||||
resumeDetection,
|
||||
setDetectionStyle,
|
||||
setDetectionTuning,
|
||||
} from '../data/detection.js';
|
||||
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
|
||||
import {
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
} from '../renderGovernor.js';
|
||||
import {
|
||||
setScopeMaskEnabled,
|
||||
isScopeMaskEnabled,
|
||||
setScopeMaskFeather,
|
||||
getScopeMaskFeather,
|
||||
setScopeTerminusOverride,
|
||||
getScopeTerminusOverride,
|
||||
clampScopeTerminusPct,
|
||||
} from '../scopeMask.js';
|
||||
import {
|
||||
fetchRegionalBrief,
|
||||
regionalDistanceM,
|
||||
weatherCodeLabel,
|
||||
} from '../data/regionalBrief.js';
|
||||
|
||||
export class StyleManager extends ApplicationShell {
|
||||
constructor(viewer, options = {}) {
|
||||
super(viewer, {
|
||||
...options,
|
||||
services: {
|
||||
CITY_POIS,
|
||||
GLOBE_VIEW,
|
||||
flyToGlobeView,
|
||||
flyToPresetLocation,
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
OrbitController,
|
||||
CelestialRing,
|
||||
getKeyholeFadeTuning,
|
||||
isCelestialRingStyleSupported,
|
||||
setKeyholeFadeTuning,
|
||||
destroyTrackedReadout,
|
||||
initTrackedReadout,
|
||||
destroyWorldOverlay,
|
||||
initWorldOverlay,
|
||||
subscribeWorldOverlayActions,
|
||||
activateWorldOverlayAction,
|
||||
destroyDetection,
|
||||
initDetection,
|
||||
cycleDetectionMode,
|
||||
readDetectionDiagnostics,
|
||||
getDetectionTuning,
|
||||
getDetectionMode,
|
||||
setDetectionModeByLabel,
|
||||
suspendDetection,
|
||||
resumeDetection,
|
||||
setDetectionStyle,
|
||||
setDetectionTuning,
|
||||
isTr3b,
|
||||
toggleTr3b,
|
||||
holdContinuousRender,
|
||||
releaseContinuousRender,
|
||||
governorRequestRender,
|
||||
setScopeMaskEnabled,
|
||||
isScopeMaskEnabled,
|
||||
setScopeMaskFeather,
|
||||
getScopeMaskFeather,
|
||||
setScopeTerminusOverride,
|
||||
getScopeTerminusOverride,
|
||||
clampScopeTerminusPct,
|
||||
fetchRegionalBrief,
|
||||
regionalDistanceM,
|
||||
weatherCodeLabel,
|
||||
LocationSearch,
|
||||
...options.services,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ContextControls } from './contextControls.js';
|
||||
@@ -0,0 +1,270 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import {
|
||||
settleUserFacingContextAction,
|
||||
contextModeWord,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export async function _runUserFacingContextAction(
|
||||
operation,
|
||||
message = t('Context could not restore every layer; try again'),
|
||||
{ falseIsFailure = true, localizedMessage = null } = {},
|
||||
) {
|
||||
if (this.destroyed) return false;
|
||||
const notificationToken = Symbol('user-facing-context-action');
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
try {
|
||||
return await settleUserFacingContextAction({
|
||||
operation: () => operation(notificationToken),
|
||||
falseIsFailure,
|
||||
onFailure: (error) => {
|
||||
if (this.destroyed) return;
|
||||
console.warn('[Context] user-facing transition failed', error);
|
||||
this.showToast(localizedMessage || message);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim the visual restore lane for an explicit Context transition.
|
||||
*
|
||||
* Contacts OWNS detection while active (forced Dense @ 75%), so entering or
|
||||
* leaving it is a visual-lane gesture exactly like the HUD or detection
|
||||
* controls. Without this claim, the shared-view restore that lands 1.5 s into
|
||||
* startup re-applied the link's `dm`/`dd` over the forced preset and Contacts
|
||||
* lost its own overlay mid-session.
|
||||
*
|
||||
* Deliberately does NOT set `_detectionUserOverridden`: that flag means the
|
||||
* OPERATOR hand-edited detection and suppresses the military-style
|
||||
* auto-enable for the rest of the session. Context entry is not that, and
|
||||
* conflating them would silently disable a separate landed behavior.
|
||||
*
|
||||
* Call only for VALIDATED explicit transitions — never for programmatic or
|
||||
* restore-driven ones, which must stay eligible for the shared visual state.
|
||||
*/
|
||||
export function _claimContextVisualAuthority() {
|
||||
this.actions.claimVisualAuthority();
|
||||
}
|
||||
|
||||
export function getContextModeState() {
|
||||
return {
|
||||
mode: this._contextMode || null,
|
||||
active: Boolean(this._contextMode),
|
||||
changing: Boolean(this._contextModeChanging),
|
||||
entering: this._contextModeEntering || null,
|
||||
canContact: !this._contextMode || this._contextMode === 'flights',
|
||||
canMission: !this._contextMode || this._contextMode === 'space-missions',
|
||||
snapshotCaptured: Boolean(this._contextSessionSnapshot),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setContextMode(
|
||||
mode,
|
||||
{
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
isCurrent = null,
|
||||
claimVisualAuthority = true,
|
||||
} = {},
|
||||
) {
|
||||
const requestIsCurrent = () =>
|
||||
!this.destroyed &&
|
||||
!signal?.aborted &&
|
||||
(typeof isCurrent !== 'function' || isCurrent());
|
||||
const cancellationResult = () => ({
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
cancelled: true,
|
||||
error: 'Context request was superseded by a newer voice turn',
|
||||
...this.getContextModeState(),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
});
|
||||
if (!requestIsCurrent()) return cancellationResult();
|
||||
try {
|
||||
if (!mode || mode === 'off') {
|
||||
// Validated explicit transition — Context owns detection, so take the
|
||||
// visual lane before a delayed shared restore can reclaim it. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const result = await this._selectContextMode(null, {
|
||||
notificationToken,
|
||||
signal,
|
||||
});
|
||||
if (result === null || (!requestIsCurrent() && result !== true))
|
||||
return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
return {
|
||||
ok: result === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(result === true
|
||||
? {}
|
||||
: { error: 'Context mode transition did not complete' }),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const canonical = mode === 'contacts' ? 'flights' : mode;
|
||||
if (!['flights', 'space-missions'].includes(canonical)) {
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: `Unknown context mode: ${mode}`,
|
||||
mode: this._contextMode,
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
const priorMode = this._contextMode;
|
||||
// Claimed only after the mode enum validates above, so a rejected request
|
||||
// takes no authority and leaves the shared visual state eligible. Internal
|
||||
// Cockpit choreography opts out: it is not an operator Context request.
|
||||
if (claimVisualAuthority) this._claimContextVisualAuthority();
|
||||
const transitioned = await this._selectContextMode(canonical, {
|
||||
notificationToken,
|
||||
signal,
|
||||
});
|
||||
if (transitioned === null || (!requestIsCurrent() && transitioned !== true))
|
||||
return cancellationResult();
|
||||
const state = this.getContextModeState();
|
||||
// A cross-mode switch tears the prior mode down before it commits, so a
|
||||
// cancelled or failed switch rests on Context OFF. Say that plainly:
|
||||
// reporting a bare "did not complete" while the operator's Context is
|
||||
// gone is the dishonesty this whole path was fixed for. The state fields
|
||||
// below carry the same verdict, so text and state cannot disagree.
|
||||
const crossModeSwitchLost =
|
||||
transitioned !== true &&
|
||||
Boolean(priorMode) &&
|
||||
priorMode !== canonical &&
|
||||
!state.mode;
|
||||
return {
|
||||
ok: transitioned === true,
|
||||
action: 'set_context_mode',
|
||||
mode: state.mode,
|
||||
...state,
|
||||
...(transitioned === true
|
||||
? {}
|
||||
: {
|
||||
// Named in the operator's vocabulary, not the internal id: this
|
||||
// string is read by the voice model, which takes 'contacts'.
|
||||
error: crossModeSwitchLost
|
||||
? `Switch to ${contextModeWord(canonical)} did not complete — Context is now off`
|
||||
: 'Context mode transition did not complete',
|
||||
...(crossModeSwitchLost ? { contextOff: true, priorMode } : {}),
|
||||
}),
|
||||
...(this._contextTransitionFailedLayerIds?.length
|
||||
? { failedLayerIds: [...this._contextTransitionFailedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (!requestIsCurrent()) {
|
||||
return {
|
||||
...cancellationResult(),
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
action: 'set_context_mode',
|
||||
error: error?.message || 'Context mode transition failed',
|
||||
...(Array.isArray(error?.failedLayerIds)
|
||||
? { failedLayerIds: [...error.failedLayerIds] }
|
||||
: {}),
|
||||
...this.getContextModeState(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSelectedLayers() {
|
||||
if (this.destroyed)
|
||||
return Promise.resolve({
|
||||
targetIds: [],
|
||||
items: [],
|
||||
clearedIds: [],
|
||||
notClearedIds: [],
|
||||
cancelled: true,
|
||||
});
|
||||
if (this._clearSelectedLayersPromise) return this._clearSelectedLayersPromise;
|
||||
if (!this._dataManager?.clearSelectedLayers) {
|
||||
return Promise.resolve({
|
||||
targetIds: [],
|
||||
items: [],
|
||||
clearedIds: [],
|
||||
notClearedIds: [],
|
||||
});
|
||||
}
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const notificationToken = Symbol('clear-selected-layers');
|
||||
if (this._contextRestoreState) this._contextRestoreState.cancelled = true;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextRestoreState = null;
|
||||
this._preservePanelStateDuringLayerClear = true;
|
||||
this._syncContextModeButtons();
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
this.setClearBusy(true);
|
||||
|
||||
const managerOperation = this._dataManager.clearSelectedLayers({
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
});
|
||||
this._clearSelectedLayersManagerPromise = managerOperation;
|
||||
const operation = managerOperation
|
||||
.then((result) => {
|
||||
if (result.targetIds.length === 0) {
|
||||
this.showToast(t('No selected data layers'));
|
||||
} else if (result.notClearedIds.length > 0) {
|
||||
this.showToast(
|
||||
tn(
|
||||
result.notClearedIds.length,
|
||||
'{count} data layer could not be cleared',
|
||||
'{count} data layers could not be cleared',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.showToast(
|
||||
tn(
|
||||
result.clearedIds.length,
|
||||
'Cleared {count} data layer',
|
||||
'Cleared {count} data layers',
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[Data] clear selected layers failed', error);
|
||||
this.showToast(t('Selected data layers could not be cleared'));
|
||||
return {
|
||||
targetIds: [],
|
||||
items: [],
|
||||
clearedIds: [],
|
||||
notClearedIds: [],
|
||||
error,
|
||||
};
|
||||
})
|
||||
.finally(() => {
|
||||
this._userFacingContextNotificationTokens.delete(notificationToken);
|
||||
if (generation === this._contextModeGeneration) {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
this.setClearBusy(false);
|
||||
this._preservePanelStateDuringLayerClear = false;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._clearSelectedLayersPromise = null;
|
||||
});
|
||||
this._clearSelectedLayersPromise = operation;
|
||||
return operation;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
|
||||
|
||||
export function _initGlobalContextPanel() {
|
||||
const contextTabs = [
|
||||
this._globalContextFlightsBtn,
|
||||
this._globalContextMissionsBtn,
|
||||
].filter(Boolean);
|
||||
contextTabs.forEach((tab, index) =>
|
||||
this.listen(tab, 'keydown', (event) => {
|
||||
let nextIndex = null;
|
||||
if (event.key === 'ArrowRight')
|
||||
nextIndex = (index + 1) % contextTabs.length;
|
||||
else if (event.key === 'ArrowLeft')
|
||||
nextIndex = (index - 1 + contextTabs.length) % contextTabs.length;
|
||||
else if (event.key === 'Home') nextIndex = 0;
|
||||
else if (event.key === 'End') nextIndex = contextTabs.length - 1;
|
||||
if (nextIndex === null) return;
|
||||
event.preventDefault();
|
||||
contextTabs[nextIndex].focus({ preventScroll: true });
|
||||
contextTabs[nextIndex].click();
|
||||
}),
|
||||
);
|
||||
this.listen(this._globalContextFlightsBtn, 'click', () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
this._contextModeChanging ||
|
||||
this._clearSelectedLayersPromise
|
||||
)
|
||||
return;
|
||||
const nextMode = this._contextMode === 'flights' ? null : 'flights';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
t('Contacts could not complete the requested transition; try again'),
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
nextMode &&
|
||||
shouldExpandGlobalContextPanel({
|
||||
action: 'contacts',
|
||||
explicitUserAction: true,
|
||||
succeeded: succeeded === true,
|
||||
})
|
||||
)
|
||||
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
this.listen(this._globalContextMissionsBtn, 'click', () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
this._contextModeChanging ||
|
||||
this._clearSelectedLayersPromise
|
||||
)
|
||||
return;
|
||||
const nextMode =
|
||||
this._contextMode === 'space-missions' ? null : 'space-missions';
|
||||
this._claimContextVisualAuthority();
|
||||
void this._runUserFacingContextAction(
|
||||
(notificationToken) =>
|
||||
this._selectContextMode(nextMode, { notificationToken }),
|
||||
t(
|
||||
'Space Missions could not complete the requested transition; try again',
|
||||
),
|
||||
).then((succeeded) => {
|
||||
if (
|
||||
!this.destroyed &&
|
||||
nextMode &&
|
||||
shouldExpandGlobalContextPanel({
|
||||
action: 'space-missions',
|
||||
explicitUserAction: true,
|
||||
succeeded: succeeded === true,
|
||||
})
|
||||
)
|
||||
this.actions.setPanelCollapsed('global-context-panel', false, {
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
this.listen(this._installationsSearchBtn, 'click', () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this._dataManager?.layers?.has('military-installations')
|
||||
)
|
||||
return;
|
||||
if (this._installationsSearchBusy) return;
|
||||
this._installationsSearchBusy = true;
|
||||
this._presentContext();
|
||||
void this._runUserFacingContextAction(async (notificationToken) => {
|
||||
const enabled = await this._dataManager.setEnabled(
|
||||
'military-installations',
|
||||
true,
|
||||
{
|
||||
origin: 'user',
|
||||
notificationToken,
|
||||
},
|
||||
);
|
||||
if (
|
||||
this.destroyed ||
|
||||
enabled === false ||
|
||||
!this._dataManager.isEnabled('military-installations')
|
||||
)
|
||||
return false;
|
||||
const searched = await this.installations.searchNearby?.();
|
||||
if (this.destroyed || searched === false) return false;
|
||||
const stats = this.installations.getStats?.();
|
||||
this.showToast(
|
||||
stats?.statusMessage ||
|
||||
(stats?.status === 'zoom-in'
|
||||
? t('Zoom in to search mapped installations')
|
||||
: t('Nearby installations refreshed')),
|
||||
);
|
||||
return true;
|
||||
}, t('Nearby installations could not be refreshed; try again')).finally(
|
||||
() => {
|
||||
if (this.destroyed) return;
|
||||
this._installationsSearchBusy = false;
|
||||
this._presentContext();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { _initGlobalContextPanel } from './contextBindings.js';
|
||||
import {
|
||||
_syncContextModeButtons,
|
||||
_presentContext,
|
||||
} from './contextPresentation.js';
|
||||
import {
|
||||
_trackContextLayerReaction,
|
||||
_waitForContextLayerSettlement,
|
||||
_captureContextSessionSnapshot,
|
||||
_restoreContextSession,
|
||||
_restoreContextSessionAfterLayerSettles,
|
||||
} from './contextSession.js';
|
||||
import {
|
||||
_selectContextMode,
|
||||
_deactivateContextForLayerChange,
|
||||
_clearLayersOutsideContextMode,
|
||||
} from './contextTransactions.js';
|
||||
import { _handleContextLayerChange } from './contextLayerChanges.js';
|
||||
import {
|
||||
_runUserFacingContextAction,
|
||||
_claimContextVisualAuthority,
|
||||
getContextModeState,
|
||||
setContextMode,
|
||||
clearSelectedLayers,
|
||||
} from './contextActions.js';
|
||||
import { connectContextManager } from './contextSubscriptions.js';
|
||||
|
||||
/**
|
||||
* Own Context transactions and controls through explicit layer and
|
||||
* presentation ports. The mode switch is presented through `publish`
|
||||
* (the app hands it the `gev-context` store) and rendered by the view.
|
||||
*/
|
||||
export class ContextControls {
|
||||
constructor({ elements, installations, actions, publish = () => {} }) {
|
||||
Object.assign(this, elements);
|
||||
this.installations = installations;
|
||||
this.actions = actions;
|
||||
this.publish = publish;
|
||||
this._installationsSearchBusy = false;
|
||||
this.listeners = new AbortController();
|
||||
this.destroyed = false;
|
||||
this._dataManager = null;
|
||||
this._contextMode = null;
|
||||
this._contextModeChanging = false;
|
||||
this._contextModeGeneration = 0;
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextRestoreState = null;
|
||||
this._contextLayerReactionPromises = new Set();
|
||||
this._preservePanelStateDuringLayerClear = false;
|
||||
this._userFacingContextNotificationTokens = new Set();
|
||||
this._clearSelectedLayersPromise = null;
|
||||
this._clearSelectedLayersManagerPromise = null;
|
||||
this._initGlobalContextPanel();
|
||||
}
|
||||
showToast(...args) {
|
||||
if (!this.destroyed) this.actions.showToast(...args);
|
||||
}
|
||||
setClearBusy(...args) {
|
||||
if (!this.destroyed) this.actions.setClearBusy(...args);
|
||||
}
|
||||
get cockpitView() {
|
||||
return this.actions.getCockpit();
|
||||
}
|
||||
listen(target, type, callback, options = {}) {
|
||||
target?.addEventListener(type, callback, {
|
||||
...options,
|
||||
signal: this.listeners.signal,
|
||||
});
|
||||
}
|
||||
connect(manager) {
|
||||
return connectContextManager.call(this, manager);
|
||||
}
|
||||
disconnect() {
|
||||
for (const field of [
|
||||
'_contextManagerUnsubscribe',
|
||||
'_dataManagerBeforeDestroyUnsubscribe',
|
||||
'_dataManagerVisibilityGuardUnsubscribe',
|
||||
'_dataManagerVisibilityRequestUnsubscribe',
|
||||
]) {
|
||||
this[field]?.();
|
||||
this[field] = null;
|
||||
}
|
||||
}
|
||||
stop() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this._contextModeGeneration++;
|
||||
this.listeners.abort();
|
||||
}
|
||||
async restoreForDisposal() {
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession();
|
||||
}
|
||||
_initGlobalContextPanel(...args) {
|
||||
return _initGlobalContextPanel.call(this, ...args);
|
||||
}
|
||||
_syncContextModeButtons(...args) {
|
||||
return _syncContextModeButtons.call(this, ...args);
|
||||
}
|
||||
_presentContext(...args) {
|
||||
return _presentContext.call(this, ...args);
|
||||
}
|
||||
_trackContextLayerReaction(...args) {
|
||||
return _trackContextLayerReaction.call(this, ...args);
|
||||
}
|
||||
_waitForContextLayerSettlement(...args) {
|
||||
return _waitForContextLayerSettlement.call(this, ...args);
|
||||
}
|
||||
_captureContextSessionSnapshot(...args) {
|
||||
return _captureContextSessionSnapshot.call(this, ...args);
|
||||
}
|
||||
_restoreContextSession(...args) {
|
||||
return _restoreContextSession.call(this, ...args);
|
||||
}
|
||||
_restoreContextSessionAfterLayerSettles(...args) {
|
||||
return _restoreContextSessionAfterLayerSettles.call(this, ...args);
|
||||
}
|
||||
_selectContextMode(...args) {
|
||||
return _selectContextMode.call(this, ...args);
|
||||
}
|
||||
_deactivateContextForLayerChange(...args) {
|
||||
return _deactivateContextForLayerChange.call(this, ...args);
|
||||
}
|
||||
_clearLayersOutsideContextMode(...args) {
|
||||
return _clearLayersOutsideContextMode.call(this, ...args);
|
||||
}
|
||||
_handleContextLayerChange(...args) {
|
||||
return _handleContextLayerChange.call(this, ...args);
|
||||
}
|
||||
_runUserFacingContextAction(...args) {
|
||||
return _runUserFacingContextAction.call(this, ...args);
|
||||
}
|
||||
_claimContextVisualAuthority(...args) {
|
||||
return _claimContextVisualAuthority.call(this, ...args);
|
||||
}
|
||||
getContextModeState(...args) {
|
||||
return getContextModeState.call(this, ...args);
|
||||
}
|
||||
setContextMode(...args) {
|
||||
return setContextMode.call(this, ...args);
|
||||
}
|
||||
clearSelectedLayers(...args) {
|
||||
return clearSelectedLayers.call(this, ...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
|
||||
class Button extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.attributes = new Map();
|
||||
}
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name);
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, value);
|
||||
}
|
||||
}
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function fixture(t, elements = {}) {
|
||||
const calls = [];
|
||||
const views = [];
|
||||
const controls = new ContextControls({
|
||||
publish: (view) => views.push(view),
|
||||
elements,
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
showToast: () => calls.push('notice'),
|
||||
setClearBusy: () => {},
|
||||
syncDetection: () => {},
|
||||
scheduleLayout: () => {},
|
||||
refreshRadio: () => {},
|
||||
setPanelCollapsed: () => calls.push('expand'),
|
||||
},
|
||||
});
|
||||
t.after(() => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
});
|
||||
return { controls, calls, views };
|
||||
}
|
||||
|
||||
test('Context starts with an explicit idle snapshot', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
assert.deepEqual(controls.getContextModeState(), {
|
||||
mode: null,
|
||||
active: false,
|
||||
changing: false,
|
||||
entering: null,
|
||||
canContact: true,
|
||||
canMission: true,
|
||||
snapshotCaptured: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a pending Context tab cannot reopen a disposed panel', async (t) => {
|
||||
const tab = new Button();
|
||||
const { controls, calls } = fixture(t, { _globalContextFlightsBtn: tab });
|
||||
let release;
|
||||
controls._selectContextMode = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
tab.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['claim']);
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
tab.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['claim']);
|
||||
});
|
||||
|
||||
test('stopping during installations enable prevents the delayed search', async (t) => {
|
||||
const button = new Button();
|
||||
const { controls, calls, views } = fixture(t, {
|
||||
_installationsSearchBtn: button,
|
||||
});
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = () =>
|
||||
assert.fail('search started after stop');
|
||||
button.dispatchEvent(new Event('click'));
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(views.length, 1, 'disposed controls publish nothing more');
|
||||
assert.equal(views[0].installationsBusy, true);
|
||||
});
|
||||
|
||||
test('a late installations search cannot publish a notice after disposal', async (t) => {
|
||||
const button = new Button();
|
||||
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
|
||||
let release;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: async () => true,
|
||||
isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
button.dispatchEvent(new Event('click'));
|
||||
await turn();
|
||||
controls.stop();
|
||||
release(true);
|
||||
await turn();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('reconnecting and disposal release each manager subscription exactly once', (t) => {
|
||||
const { controls } = fixture(t);
|
||||
const counts = new Map();
|
||||
const manager = (name) =>
|
||||
Object.fromEntries(
|
||||
[
|
||||
'subscribe',
|
||||
'subscribeVisibilityRequests',
|
||||
'addVisibilityGuard',
|
||||
'subscribeBeforeDestroy',
|
||||
].map((method) => [
|
||||
method,
|
||||
() => () => {
|
||||
const key = `${name}:${method}`;
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
},
|
||||
]),
|
||||
);
|
||||
controls.connect(manager('a'));
|
||||
controls.connect(manager('b'));
|
||||
assert.equal(counts.size, 4);
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
controls.disconnect();
|
||||
controls.connect(manager('c'));
|
||||
assert.equal(counts.size, 8);
|
||||
assert.ok([...counts.values()].every((count) => count === 1));
|
||||
});
|
||||
|
||||
test('a rejected tracked reaction settles without an unhandled rejection', async (t) => {
|
||||
const { controls } = fixture(t);
|
||||
const failure = new Error('source unavailable');
|
||||
const pending = controls._trackContextLayerReaction(Promise.reject(failure));
|
||||
await assert.rejects(pending, failure);
|
||||
await controls._waitForContextLayerSettlement();
|
||||
assert.equal(controls._contextLayerReactionPromises.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// The blocked-visibility toast reason in the UI language.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import {
|
||||
contextLayerEnableBlockReason,
|
||||
parseContextLayerBlockReason,
|
||||
} from '../contextModePolicy.js';
|
||||
import {
|
||||
_handleContextLayerChange,
|
||||
localizeVisibilityBlockReason,
|
||||
} from './contextLayerChanges.js';
|
||||
|
||||
function fakeShell() {
|
||||
const toasts = [];
|
||||
return {
|
||||
destroyed: false,
|
||||
_userFacingContextNotificationTokens: new Set(),
|
||||
_contextSessionSnapshot: null,
|
||||
_contextModeChanging: false,
|
||||
_contextMode: 'space-missions',
|
||||
_contextModeEntering: null,
|
||||
_syncContextModeButtons() {},
|
||||
showToast(message) {
|
||||
toasts.push(message);
|
||||
},
|
||||
toasts,
|
||||
};
|
||||
}
|
||||
|
||||
const ENGLISH_GUARD =
|
||||
'Space Missions isolates replay data. Exit the mode to enable Live Flights.';
|
||||
|
||||
test.afterEach(() => setLocale('en'));
|
||||
|
||||
test('the guard reason is English data and parses back to its layer name', () => {
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: 'space-missions',
|
||||
change: { layerId: 'flights', enabled: true, origin: 'user' },
|
||||
layerName: 'Live Flights',
|
||||
});
|
||||
assert.equal(reason, ENGLISH_GUARD);
|
||||
assert.deepEqual(parseContextLayerBlockReason(reason), {
|
||||
layer: 'Live Flights',
|
||||
});
|
||||
assert.equal(parseContextLayerBlockReason('Data source unavailable'), null);
|
||||
assert.equal(
|
||||
parseContextLayerBlockReason(
|
||||
'Space Missions isolates replay data. Exit the mode to enable .',
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('en: the toast shows the reason unchanged', () => {
|
||||
setLocale('en');
|
||||
const shell = fakeShell();
|
||||
_handleContextLayerChange.call(shell, {
|
||||
type: 'visibility-blocked',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
reason: ENGLISH_GUARD,
|
||||
});
|
||||
assert.deepEqual(shell.toasts, [ENGLISH_GUARD]);
|
||||
assert.equal(
|
||||
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
|
||||
'Overpass returned HTTP 503',
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: the Space Missions block toast is localized with the layer name`, () => {
|
||||
setLocale(locale);
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: 'space-missions',
|
||||
change: { layerId: 'flights', enabled: true, origin: 'user' },
|
||||
layerName: 'Live Flights',
|
||||
});
|
||||
const shell = fakeShell();
|
||||
_handleContextLayerChange.call(shell, {
|
||||
type: 'visibility-blocked',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
reason,
|
||||
});
|
||||
assert.equal(shell.toasts.length, 1);
|
||||
assert.notEqual(shell.toasts[0], ENGLISH_GUARD, 'toast must be localized');
|
||||
assert.doesNotMatch(shell.toasts[0], /Space Missions isolates/);
|
||||
assert.doesNotMatch(shell.toasts[0], /Live Flights/);
|
||||
});
|
||||
|
||||
test(`${locale}: a known lifecycle reason is looked up in the catalog`, () => {
|
||||
setLocale(locale);
|
||||
const shell = fakeShell();
|
||||
shell._contextMode = null;
|
||||
_handleContextLayerChange.call(shell, {
|
||||
type: 'visibility-blocked',
|
||||
layerId: 'street-level',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
reason: 'Data source unavailable',
|
||||
});
|
||||
assert.equal(shell.toasts.length, 1);
|
||||
assert.notEqual(shell.toasts[0], 'Data source unavailable');
|
||||
});
|
||||
|
||||
test(`${locale}: an unknown provider message passes through verbatim`, () => {
|
||||
setLocale(locale);
|
||||
assert.equal(
|
||||
localizeVisibilityBlockReason('Overpass returned HTTP 503'),
|
||||
'Overpass returned HTTP 503',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
import {
|
||||
SPACE_MISSIONS_LAYER_BLOCK_REASON,
|
||||
cockpitEntryAllowed,
|
||||
isExplicitUserIntentOrigin,
|
||||
parseContextLayerBlockReason,
|
||||
recordContextRestoreExplicitChange,
|
||||
recordContextSessionUserChange,
|
||||
shouldExitContextForLayerChange,
|
||||
spaceMissionEntryCancellationDisposition,
|
||||
} from '../contextModePolicy.js';
|
||||
import { localizeLayerName } from './layerNames.js';
|
||||
|
||||
/**
|
||||
* A blocked-visibility reason in the UI language. Reasons stay English (voice
|
||||
* and tools read them): the Space Missions refusal is rebuilt with the layer
|
||||
* name localized, a known sentence is looked up, anything else passes.
|
||||
*/
|
||||
export function localizeVisibilityBlockReason(reason) {
|
||||
if (typeof reason !== 'string' || !reason) return reason;
|
||||
const blocked = parseContextLayerBlockReason(reason);
|
||||
if (blocked)
|
||||
return t(SPACE_MISSIONS_LAYER_BLOCK_REASON, {
|
||||
layer: localizeLayerName(blocked.layer),
|
||||
});
|
||||
return translateKnown(reason);
|
||||
}
|
||||
|
||||
export function _handleContextLayerChange(change) {
|
||||
if (this.destroyed) return;
|
||||
if (
|
||||
change?.layerId === 'radio' &&
|
||||
[
|
||||
'visibility-transition',
|
||||
'visibility',
|
||||
'visibility-cancelled',
|
||||
'visibility-failed',
|
||||
].includes(change.type)
|
||||
) {
|
||||
this.actions.refreshRadio();
|
||||
}
|
||||
if (change?.type === 'visibility-transition') return;
|
||||
// The effective mode must be read BEFORE the entering flag is cleared:
|
||||
// the entry layer's own enable event is the one that clears it, and the
|
||||
// session bookkeeping below needs to know a mode was being entered.
|
||||
const effectiveContextMode = this._contextModeEntering || this._contextMode;
|
||||
if (change?.type === 'visibility-cancelled') {
|
||||
const cancellationDisposition = spaceMissionEntryCancellationDisposition({
|
||||
change,
|
||||
});
|
||||
if (
|
||||
this._contextModeDeferredEntryIntent?.layerId === change.layerId &&
|
||||
this._contextModeDeferredEntryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
if (cancellationDisposition !== 'replacement') {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
}
|
||||
if (cancellationDisposition === 'replacement') {
|
||||
this._contextModeEntering = 'space-missions';
|
||||
const entryIntent = this._contextModeEntryIntent;
|
||||
if (
|
||||
entryIntent?.generation === this._contextModeGeneration &&
|
||||
entryIntent.layerId === change.layerId &&
|
||||
entryIntent.intentEpoch === change.intentEpoch
|
||||
) {
|
||||
this._contextModeReplacementIntent = {
|
||||
generation: entryIntent.generation,
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.successorIntentEpoch,
|
||||
};
|
||||
}
|
||||
} else if (cancellationDisposition === 'restore') {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this._contextSessionSnapshot && !this._contextModeChanging) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction(
|
||||
async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(
|
||||
change.layerId,
|
||||
{
|
||||
notificationToken,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'Space Missions cancellation could not restore the previous layer state', // i18n-ignore: English message; the toast shows localizedMessage
|
||||
{
|
||||
localizedMessage: t(
|
||||
'Space Missions cancellation could not restore the previous layer state',
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
change?.layerId === 'rocket-launches' &&
|
||||
['visibility', 'visibility-blocked', 'visibility-failed'].includes(
|
||||
change.type,
|
||||
)
|
||||
) {
|
||||
this._contextModeEntering = null;
|
||||
}
|
||||
if (change?.type === 'visibility-blocked') {
|
||||
if (
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(
|
||||
localizeVisibilityBlockReason(change.reason) ||
|
||||
t('That layer is unavailable in the current Context mode'),
|
||||
);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-failed') {
|
||||
const failureMessage = t('{layerId} could not {action} cleanly', {
|
||||
layerId: change.layerId,
|
||||
action: change.enabled ? t('start') : t('stop'),
|
||||
});
|
||||
// A failed direct Context-shell START has already had its siblings
|
||||
// cleared by the visibility guard. Wait outside the synchronous manager
|
||||
// notification for this queue to settle, then reconcile the complete
|
||||
// snapshot, including an uncertain failed shell.
|
||||
const needsDeferredShellRestore =
|
||||
['military-awareness', 'rocket-launches'].includes(change.layerId) &&
|
||||
change.enabled &&
|
||||
this._contextSessionSnapshot &&
|
||||
!this._contextModeChanging;
|
||||
if (needsDeferredShellRestore) {
|
||||
this._contextMode = null;
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction(async (notificationToken) => {
|
||||
await this._restoreContextSessionAfterLayerSettles(change.layerId, {
|
||||
notificationToken,
|
||||
});
|
||||
// The wrapper owns failure announcements. On a successful rollback
|
||||
// announce the original activation failure here so the same direct
|
||||
// action still produces exactly one accessible notification.
|
||||
this.showToast(failureMessage);
|
||||
return true;
|
||||
}, failureMessage),
|
||||
);
|
||||
} else if (
|
||||
!this._userFacingContextNotificationTokens.has(change.notificationToken)
|
||||
) {
|
||||
this.showToast(failureMessage);
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
return;
|
||||
}
|
||||
if (change?.type === 'visibility-will-change') {
|
||||
// Explicit entry capture happens on the synchronous visibility-requested
|
||||
// boundary. Keeping this later branch side-effect free prevents an
|
||||
// awaited Clear/guard from replacing that authoritative pre-entry view.
|
||||
return;
|
||||
}
|
||||
// Session bookkeeping must run BEFORE any exit path below: the exit
|
||||
// handlers restore `snapshot ∪ userAdded`, so a stale entry here becomes
|
||||
// a layer resurrected against the user's explicit disable.
|
||||
recordContextSessionUserChange({
|
||||
snapshot: this._contextSessionSnapshot,
|
||||
change,
|
||||
effectiveContextMode,
|
||||
});
|
||||
recordContextRestoreExplicitChange({
|
||||
restoreState: this._contextRestoreState,
|
||||
change,
|
||||
});
|
||||
if (
|
||||
shouldExitContextForLayerChange({
|
||||
contextMode: this._contextMode,
|
||||
globalContextEnabled:
|
||||
!!this._dataManager?.isEnabled('military-awareness'),
|
||||
change,
|
||||
})
|
||||
) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!this._contextModeChanging) {
|
||||
if (change.layerId === 'military-awareness') {
|
||||
// The coordinator remains manager-addressable for restoration and
|
||||
// programmatic routes, but Contacts is selected only from the
|
||||
// dedicated right-side Global Context chooser.
|
||||
this._contextMode = change.enabled
|
||||
? null
|
||||
: this._contextMode === 'flights'
|
||||
? null
|
||||
: this._contextMode;
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (change.layerId === 'rocket-launches') {
|
||||
const ownsContextEntry =
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId) ||
|
||||
this._contextMode === 'space-missions' ||
|
||||
effectiveContextMode === 'space-missions';
|
||||
if (!ownsContextEntry) return;
|
||||
this._contextMode = change.enabled
|
||||
? 'space-missions'
|
||||
: this._contextMode === 'space-missions'
|
||||
? null
|
||||
: this._contextMode;
|
||||
if (change.enabled) {
|
||||
this._syncContextModeButtons();
|
||||
} else if (this._contextSessionSnapshot) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
this._contextMode === 'flights' &&
|
||||
[
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
].includes(change.layerId) &&
|
||||
!change.enabled
|
||||
) {
|
||||
void this._trackContextLayerReaction(
|
||||
this._runUserFacingContextAction((notificationToken) =>
|
||||
this._deactivateContextForLayerChange({ notificationToken }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.cockpitView?.active &&
|
||||
!cockpitEntryAllowed({
|
||||
contextMode: this._contextMode,
|
||||
contextModeChanging: this._contextModeChanging,
|
||||
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
|
||||
militaryEnabled: !!this._dataManager?.isEnabled('military'),
|
||||
})
|
||||
) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// src/ui/contextPanel.test.mjs — the Context mode switch renders what
|
||||
// ContextControls presents (`_presentContext()`), over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useContextStore } from './stores/context.js';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
|
||||
const turn = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ContextView).use(pinia);
|
||||
app.mount(host);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const store = useContextStore(pinia);
|
||||
const controls = new ContextControls({
|
||||
elements: {
|
||||
_globalContextFlightsBtn: $('global-context-flights-btn'),
|
||||
_globalContextMissionsBtn: $('global-context-missions-btn'),
|
||||
_installationsSearchBtn: $('installations-search-btn'),
|
||||
},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority() {},
|
||||
showToast() {},
|
||||
setClearBusy() {},
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
refreshRadio() {},
|
||||
setPanelCollapsed() {},
|
||||
},
|
||||
publish: (view) => store.publish(view),
|
||||
});
|
||||
t.after(() => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
app.unmount();
|
||||
});
|
||||
const sync = async () => {
|
||||
controls._syncContextModeButtons();
|
||||
await nextTick();
|
||||
};
|
||||
return { $, controls, sync };
|
||||
}
|
||||
|
||||
test('before ContextControls renders, the switch shows its initial markup', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
const panel = $('global-context-panel');
|
||||
assert.equal(
|
||||
panel.className,
|
||||
'ui-panel panel-collapsible collapsed ui-panel--collapsed',
|
||||
);
|
||||
assert.equal(panel.hasAttribute('data-context-mode'), false);
|
||||
for (const id of [
|
||||
'global-context-flights-btn',
|
||||
'global-context-missions-btn',
|
||||
]) {
|
||||
const tab = $(id);
|
||||
assert.equal(tab.className, 'context-mode-button');
|
||||
assert.equal(tab.getAttribute('aria-selected'), 'false');
|
||||
assert.equal(tab.getAttribute('tabindex'), '0');
|
||||
assert.equal(tab.hasAttribute('aria-busy'), false);
|
||||
}
|
||||
assert.equal($('context-mode-standby').hidden, false);
|
||||
assert.equal($('context-flights-view').hidden, true);
|
||||
assert.equal($('context-missions-view').hidden, true);
|
||||
assert.equal($('installations-search-btn').hasAttribute('aria-busy'), false);
|
||||
});
|
||||
|
||||
test('a transition keeps focus and Tab on both tabs until it settles', async (t) => {
|
||||
const { $, controls, sync } = fixture(t);
|
||||
const contacts = $('global-context-flights-btn');
|
||||
const missions = $('global-context-missions-btn');
|
||||
missions.focus();
|
||||
controls._contextModeChanging = true;
|
||||
await sync();
|
||||
assert.equal(
|
||||
document.activeElement === missions,
|
||||
true,
|
||||
'busy render keeps focus',
|
||||
);
|
||||
for (const tab of [contacts, missions]) {
|
||||
assert.equal(tab.disabled, false);
|
||||
assert.equal(tab.getAttribute('tabindex'), '0');
|
||||
assert.equal(tab.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(tab.getAttribute('aria-busy'), 'true');
|
||||
}
|
||||
|
||||
controls._contextModeChanging = false;
|
||||
controls._contextMode = 'space-missions';
|
||||
await sync();
|
||||
assert.equal(
|
||||
document.activeElement === missions,
|
||||
true,
|
||||
'settled render keeps focus',
|
||||
);
|
||||
for (const tab of [contacts, missions]) {
|
||||
assert.equal(tab.disabled, false);
|
||||
assert.equal(tab.getAttribute('aria-disabled'), 'false');
|
||||
assert.equal(tab.getAttribute('aria-busy'), 'false');
|
||||
}
|
||||
assert.equal(missions.classList.contains('active'), true);
|
||||
assert.equal(missions.getAttribute('aria-selected'), 'true');
|
||||
assert.equal(contacts.classList.contains('active'), false);
|
||||
assert.equal(contacts.getAttribute('aria-selected'), 'false');
|
||||
assert.equal($('context-mode-standby').hidden, true);
|
||||
assert.equal($('context-flights-view').hidden, true);
|
||||
assert.equal($('context-missions-view').hidden, false);
|
||||
});
|
||||
|
||||
test('the panel carries the mode beside the classes its chrome writes', async (t) => {
|
||||
const { $, controls, sync } = fixture(t);
|
||||
const panel = $('global-context-panel');
|
||||
panel.classList.remove('collapsed');
|
||||
controls._contextMode = 'flights';
|
||||
await sync();
|
||||
// Without an owner the button, and the kit class, keep the start state.
|
||||
assert.equal(
|
||||
panel.className,
|
||||
'ui-panel panel-collapsible ui-panel--collapsed context-enabled',
|
||||
);
|
||||
assert.equal(panel.getAttribute('data-context-mode'), 'flights');
|
||||
assert.equal($('context-flights-view').hidden, false);
|
||||
|
||||
panel.classList.add('collapsed');
|
||||
controls._contextMode = null;
|
||||
await sync();
|
||||
assert.equal(
|
||||
panel.className,
|
||||
'ui-panel panel-collapsible ui-panel--collapsed collapsed',
|
||||
);
|
||||
assert.equal(panel.getAttribute('data-context-mode'), 'none');
|
||||
assert.equal($('context-mode-standby').hidden, false);
|
||||
assert.equal($('context-flights-view').hidden, true);
|
||||
});
|
||||
|
||||
test('the nearby sites search is busy, without native disable, until it settles', async (t) => {
|
||||
const { $, controls } = fixture(t);
|
||||
const button = $('installations-search-btn');
|
||||
let release;
|
||||
let enables = 0;
|
||||
controls._dataManager = {
|
||||
layers: new Map([['military-installations', {}]]),
|
||||
setEnabled: () => {
|
||||
enables += 1;
|
||||
return new Promise((resolve) => (release = resolve));
|
||||
},
|
||||
isEnabled: () => true,
|
||||
};
|
||||
controls.installations.searchNearby = async () => true;
|
||||
button.focus();
|
||||
button.click();
|
||||
await nextTick();
|
||||
assert.equal(button.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(button.disabled, false);
|
||||
assert.equal(document.activeElement === button, true);
|
||||
button.click();
|
||||
assert.equal(enables, 1, 'a busy search ignores a second click');
|
||||
|
||||
release(true);
|
||||
await turn();
|
||||
await nextTick();
|
||||
assert.equal(button.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(button.getAttribute('aria-disabled'), 'false');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export function _syncContextModeButtons() {
|
||||
if (this.destroyed) return;
|
||||
this._presentContext();
|
||||
this.cockpitView?.syncEntry();
|
||||
// Every _contextMode mutation funnels through here; the sync no-ops until
|
||||
// the transaction settles, so this is the activation/deactivation edge.
|
||||
this.actions.syncDetection();
|
||||
this.actions.scheduleLayout();
|
||||
}
|
||||
|
||||
/** Publish the mode switch and the nearby sites search to the view. */
|
||||
export function _presentContext() {
|
||||
if (this.destroyed) return;
|
||||
const flightsActive = this._contextMode === 'flights';
|
||||
const missionsActive = this._contextMode === 'space-missions';
|
||||
// Both Context choices stay in the ordinary Tab sequence and are never
|
||||
// native-disabled. Arrow keys still provide tablist navigation, but must
|
||||
// not be the only way to reach Space Missions from the keyboard. Semantic
|
||||
// busy state keeps them perceivable while synchronous click guards prevent
|
||||
// a second transition.
|
||||
this.publish({
|
||||
mode: this._contextMode || 'none',
|
||||
enabled: flightsActive || missionsActive,
|
||||
flightsActive,
|
||||
missionsActive,
|
||||
busy: Boolean(this._contextModeChanging),
|
||||
installationsBusy: this._installationsSearchBusy,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
contextRestoreLayerIds,
|
||||
contextSnapshotLayerIds,
|
||||
mergeContextTransitionErrors,
|
||||
settleContextIntentReplay,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export function _trackContextLayerReaction(promise) {
|
||||
const tracked = Promise.resolve(promise);
|
||||
this._contextLayerReactionPromises.add(tracked);
|
||||
void tracked.then(
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
() => this._contextLayerReactionPromises.delete(tracked),
|
||||
);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
export async function _waitForContextLayerSettlement() {
|
||||
while (this._contextLayerReactionPromises.size > 0) {
|
||||
await Promise.allSettled([...this._contextLayerReactionPromises]);
|
||||
}
|
||||
}
|
||||
|
||||
export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
|
||||
if (!this._dataManager || this._contextSessionSnapshot) return;
|
||||
const params = {};
|
||||
for (const layerId of ['military-awareness', 'satellites']) {
|
||||
const value = this._dataManager.getLayerParams(layerId);
|
||||
if (value) params[layerId] = value;
|
||||
}
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: contextSnapshotLayerIds(
|
||||
this._dataManager.getEnabledLayerIds(),
|
||||
this._contextRestoreState?.enabledLayerIds,
|
||||
excludeLayerIds,
|
||||
),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
export async function _restoreContextSession({
|
||||
excludeLayerIds = [],
|
||||
notificationToken = null,
|
||||
signal = null,
|
||||
} = {}) {
|
||||
const snapshot = this._contextSessionSnapshot;
|
||||
if (!snapshot || !this._dataManager) return;
|
||||
// Clear the stored session before emitting restore notifications so none
|
||||
// of those transitions can be mistaken for a fresh Context entry.
|
||||
this._contextSessionSnapshot = null;
|
||||
const restoreState = {
|
||||
enabledLayerIds: contextRestoreLayerIds(snapshot),
|
||||
explicitLayerStates: new Map(),
|
||||
};
|
||||
this._contextRestoreState = restoreState;
|
||||
for (const [layerId, params] of Object.entries(snapshot.params)) {
|
||||
this._dataManager.setLayerParams(layerId, params);
|
||||
}
|
||||
let restoreError = null;
|
||||
const restoreSnapshot = async (restoreSignal = null) => {
|
||||
// Contacts owns the dependency intents it starts. Settle that
|
||||
// coordinator before restoring the remaining snapshot, otherwise its
|
||||
// dependency releases can supersede the restore's same-target requests
|
||||
// and make a valid Contacts-to-Missions handoff look like a failure.
|
||||
const contactsCoordinatorId = 'military-awareness';
|
||||
const settleContactsCoordinator =
|
||||
!restoreState.enabledLayerIds.has(contactsCoordinatorId) &&
|
||||
this._dataManager.isEffectivelyEnabled(contactsCoordinatorId);
|
||||
if (settleContactsCoordinator) {
|
||||
const coordinatorSettled = await this._dataManager.setEnabled(
|
||||
contactsCoordinatorId,
|
||||
false,
|
||||
{
|
||||
origin: 'context-restore',
|
||||
...(notificationToken ? { notificationToken } : {}),
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
},
|
||||
);
|
||||
if (coordinatorSettled === false) {
|
||||
const error = new Error(
|
||||
'Failed to settle Contacts before restoring Context',
|
||||
);
|
||||
error.failedLayerIds = [contactsCoordinatorId];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Clear All may have run while Contacts settled. It owns the selection
|
||||
// now, so the snapshot must not come back behind it.
|
||||
if (restoreState.cancelled) return;
|
||||
await this._dataManager.restoreEnabledLayerIds(
|
||||
restoreState.enabledLayerIds,
|
||||
{
|
||||
origin: 'context-restore',
|
||||
excludeLayerIds: settleContactsCoordinator
|
||||
? [...excludeLayerIds, contactsCoordinatorId]
|
||||
: excludeLayerIds,
|
||||
notificationToken,
|
||||
...(restoreSignal ? { signal: restoreSignal } : {}),
|
||||
},
|
||||
);
|
||||
};
|
||||
try {
|
||||
await restoreSnapshot(signal);
|
||||
} catch (error) {
|
||||
restoreError = error;
|
||||
// A caller abort can arrive after only part of the exact restore has
|
||||
// settled. Finish that same target without the stale caller signal while
|
||||
// this restoreState still records newer explicit intents; replay below
|
||||
// then gives those newer intents final authority.
|
||||
if (signal?.aborted && !restoreState.cancelled) {
|
||||
try {
|
||||
await restoreSnapshot(null);
|
||||
restoreError = null;
|
||||
} catch (compensationError) {
|
||||
restoreError = mergeContextTransitionErrors(
|
||||
restoreError,
|
||||
compensationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (this._contextRestoreState === restoreState)
|
||||
this._contextRestoreState = null;
|
||||
}
|
||||
// Clear Selected Layers owns a newer global OFF intent. A restore that was
|
||||
// already awaiting lifecycle work must not replay its captured companion
|
||||
// intent or recreate the discarded session after Clear invalidates it.
|
||||
if (restoreState.cancelled) return;
|
||||
// A direct Radio command may finish after restore has already copied its
|
||||
// target and queued the opposite state. Replay that newer intent only
|
||||
// after the stale queue drains; the replay origin cannot recurse here.
|
||||
const replaySignal = signal?.aborted ? null : signal;
|
||||
const replayError = await settleContextIntentReplay({
|
||||
restoreState,
|
||||
setEnabled: (layerId, enabled, options = {}) =>
|
||||
this._dataManager.setEnabled(layerId, enabled, {
|
||||
...options,
|
||||
...(replaySignal ? { signal: replaySignal } : {}),
|
||||
}),
|
||||
notificationToken,
|
||||
});
|
||||
restoreError = mergeContextTransitionErrors(restoreError, replayError);
|
||||
if (restoreError && !this._contextSessionSnapshot) {
|
||||
// Keep the exact still-pending target so a later exit/teardown can retry
|
||||
// instead of silently losing the user's pre-Context layer state.
|
||||
this._contextSessionSnapshot = {
|
||||
enabledLayerIds: new Set(restoreState.enabledLayerIds),
|
||||
userAdded: new Set(),
|
||||
userRemoved: new Set(),
|
||||
params: snapshot.params,
|
||||
};
|
||||
}
|
||||
if (restoreError) throw restoreError;
|
||||
}
|
||||
|
||||
/*
|
||||
* Cross-mode cancellation and failure deliberately settle on Context OFF.
|
||||
*
|
||||
* A reinstatement transaction lived here for three review rounds and was
|
||||
* removed on purpose. Restoring the prior mode is genuinely racy: the prior
|
||||
* mode has to be read before the teardown, but a second request arriving
|
||||
* while the first reinstatement is mid-activation reads `_contextMode` as
|
||||
* null and inherits nothing, so two overlapping cancellations still land on
|
||||
* OFF — and the only fix is a cross-transaction "logical prior mode" chain,
|
||||
* which is new shared mutable state read while an earlier transaction is
|
||||
* still awaiting. That trades a rare wrong resting state for a permanent
|
||||
* interleaving hazard.
|
||||
*
|
||||
* The defect that started this was the LIE, not the OFF: the transition
|
||||
* claimed to have cancelled cleanly while silently leaving Context off. So
|
||||
* the resting state stays OFF and is REPORTED as such, with the failed layer
|
||||
* ids preserved. A restore feature can be rebuilt post-launch on the
|
||||
* generation discipline the surrounding transaction already follows.
|
||||
*/
|
||||
export async function _restoreContextSessionAfterLayerSettles(
|
||||
layerId,
|
||||
{ notificationToken = null } = {},
|
||||
) {
|
||||
await this._dataManager?.waitForLayerSettled?.(layerId);
|
||||
return this._restoreContextSession({ notificationToken });
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
import {
|
||||
contextLayerEnableBlockReason,
|
||||
isExplicitUserIntentOrigin,
|
||||
runWithContextModeChanging,
|
||||
settleContextModeChange,
|
||||
shouldCaptureContextSession,
|
||||
shouldDeferContextEntryDuringClear,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export function connectContextManager(manager) {
|
||||
this.disconnect();
|
||||
if (this.destroyed) return;
|
||||
this._dataManager = manager || null;
|
||||
this._contextManagerUnsubscribe = this._dataManager?.subscribe?.((change) => {
|
||||
if (String(change?.type || '').startsWith('visibility'))
|
||||
this._handleContextLayerChange(change);
|
||||
});
|
||||
if (typeof this._dataManager?.subscribeVisibilityRequests === 'function') {
|
||||
this._dataManagerVisibilityRequestUnsubscribe =
|
||||
this._dataManager.subscribeVisibilityRequests((change) => {
|
||||
if (this.destroyed) return;
|
||||
if (shouldCaptureContextSession(change)) {
|
||||
// This event is synchronous with intent publication, before an
|
||||
// awaited guard or Clear All can alter the rest of the layer set.
|
||||
// Manager effective visibility already includes both the new entry
|
||||
// intent and Clear's reserved OFF baseline.
|
||||
this._captureContextSessionSnapshot({
|
||||
excludeLayerIds: [change.layerId],
|
||||
});
|
||||
if (
|
||||
shouldDeferContextEntryDuringClear({
|
||||
change,
|
||||
clearInFlight: Boolean(this._clearSelectedLayersPromise),
|
||||
})
|
||||
) {
|
||||
this._contextModeDeferredEntryIntent = {
|
||||
layerId: change.layerId,
|
||||
intentEpoch: change.intentEpoch,
|
||||
origin: change.origin,
|
||||
};
|
||||
this._contextModeEntering = 'space-missions';
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
} else if (
|
||||
change?.layerId === 'rocket-launches' &&
|
||||
change.enabled === false &&
|
||||
isExplicitUserIntentOrigin(change.origin, change.layerId)
|
||||
) {
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
if (this._clearSelectedLayersPromise) {
|
||||
this._contextSessionSnapshot = null;
|
||||
this._contextModeEntering = null;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof this._dataManager?.addVisibilityGuard === 'function') {
|
||||
this._dataManagerVisibilityGuardUnsubscribe =
|
||||
this._dataManager.addVisibilityGuard(async (change) => {
|
||||
if (this.destroyed) return null;
|
||||
const layerName =
|
||||
this._dataManager?.layers?.get(change.layerId)?.module?.name ||
|
||||
change.layerId;
|
||||
const reason = contextLayerEnableBlockReason({
|
||||
contextMode: this._contextModeEntering || this._contextMode,
|
||||
change,
|
||||
layerName,
|
||||
});
|
||||
if (reason) return reason;
|
||||
if (
|
||||
change.enabled &&
|
||||
['military-awareness', 'rocket-launches'].includes(change.layerId) &&
|
||||
shouldCaptureContextSession(change) &&
|
||||
(!this._contextModeChanging ||
|
||||
(change.layerId === 'rocket-launches' &&
|
||||
this._contextModeDeferredEntryIntent?.intentEpoch ===
|
||||
change.intentEpoch))
|
||||
) {
|
||||
const entryMode =
|
||||
change.layerId === 'rocket-launches' ? 'space-missions' : null;
|
||||
const deferredClearEntry =
|
||||
this._contextModeDeferredEntryIntent?.intentEpoch ===
|
||||
change.intentEpoch;
|
||||
// A deferred entry owns the state after Clear settles. Restoring
|
||||
// Clear's transient busy flag here would leave Context stuck.
|
||||
const priorChanging = deferredClearEntry
|
||||
? false
|
||||
: this._contextModeChanging;
|
||||
const notificationToken =
|
||||
change.notificationToken || Symbol('direct-context-shell-entry');
|
||||
const ownsNotificationToken = !change.notificationToken;
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.add(notificationToken);
|
||||
}
|
||||
this._contextModeEntering = entryMode;
|
||||
this._contextModeChanging = true;
|
||||
try {
|
||||
if (deferredClearEntry) {
|
||||
await this._clearSelectedLayersManagerPromise;
|
||||
if (this.destroyed) return false;
|
||||
if (
|
||||
this._contextModeDeferredEntryIntent?.intentEpoch !==
|
||||
change.intentEpoch
|
||||
)
|
||||
return false;
|
||||
this._contextModeDeferredEntryIntent = null;
|
||||
}
|
||||
await this._clearLayersOutsideContextMode(entryMode, {
|
||||
notificationToken,
|
||||
});
|
||||
if (this.destroyed) return false;
|
||||
} catch (error) {
|
||||
this._contextModeEntering = null;
|
||||
console.warn(`[Context] ${change.layerId} isolation failed`, error);
|
||||
try {
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: [change.layerId],
|
||||
notificationToken,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
console.warn(
|
||||
`[Context] ${change.layerId} rollback failed`,
|
||||
restoreError,
|
||||
);
|
||||
}
|
||||
return t(
|
||||
'{mode} could not start because another layer did not stop cleanly',
|
||||
{
|
||||
mode:
|
||||
entryMode === 'space-missions'
|
||||
? t('Space Missions')
|
||||
: t('Context'),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (ownsNotificationToken) {
|
||||
this._userFacingContextNotificationTokens.delete(
|
||||
notificationToken,
|
||||
);
|
||||
}
|
||||
if (!this.destroyed) settleContextModeChange(this, priorChanging);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
if (typeof this._dataManager?.subscribeBeforeDestroy === 'function') {
|
||||
this._dataManagerBeforeDestroyUnsubscribe =
|
||||
this._dataManager.subscribeBeforeDestroy(async ({ layerId } = {}) => {
|
||||
if (this.destroyed || !this._contextSessionSnapshot) return;
|
||||
await runWithContextModeChanging(this, async () => {
|
||||
this._contextMode = null;
|
||||
this.cockpitView?.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: layerId ? [layerId] : [],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
contextAllowedLayerIds,
|
||||
mergeContextTransitionErrors,
|
||||
} from '../contextModePolicy.js';
|
||||
|
||||
export async function _selectContextMode(
|
||||
mode,
|
||||
{ notificationToken = null, signal = null } = {},
|
||||
) {
|
||||
if (this.destroyed || !this._dataManager) return false;
|
||||
if (this._clearSelectedLayersPromise) return false;
|
||||
this._contextTransitionFailedLayerIds = [];
|
||||
const generation = ++this._contextModeGeneration;
|
||||
const isCurrent = () =>
|
||||
!this.destroyed && generation === this._contextModeGeneration;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._contextModeChanging = true;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
if (mode !== 'flights' && this.cockpitView?.active) {
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
}
|
||||
if (!mode) {
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
return isCurrent();
|
||||
}
|
||||
// A cross-mode switch dismantles the prior mode BEFORE the new one is
|
||||
// committed. If the caller aborts in that window the switch never lands,
|
||||
// and the resting state is Context OFF — reported as such by
|
||||
// setContextMode rather than dressed up as a clean cancellation. See the
|
||||
// note above _restoreContextSessionAfterLayerSettles.
|
||||
const crossModeSwitch = Boolean(
|
||||
this._contextMode && this._contextMode !== mode,
|
||||
);
|
||||
if (crossModeSwitch) {
|
||||
this._contextMode = null;
|
||||
await this._restoreContextSession({ notificationToken, signal });
|
||||
if (!isCurrent()) return false;
|
||||
if (signal?.aborted) return false;
|
||||
}
|
||||
this._captureContextSessionSnapshot();
|
||||
this._contextMode = mode;
|
||||
this._syncContextModeButtons();
|
||||
// Replay isolation must settle before Space Missions starts. Contacts
|
||||
// keeps its non-dependency teardown in the background so slow source
|
||||
// shutdown does not delay cockpit entry.
|
||||
try {
|
||||
await this._clearLayersOutsideContextMode(mode, {
|
||||
notificationToken,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
let transitionError = error;
|
||||
console.warn(`[Context] ${mode} isolation failed`, error);
|
||||
this._contextMode = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(
|
||||
transitionError,
|
||||
restoreError,
|
||||
);
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...(transitionError.failedLayerIds || []),
|
||||
];
|
||||
throw transitionError;
|
||||
}
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...(transitionError?.failedLayerIds || []),
|
||||
];
|
||||
return false;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
// Entry is one transaction: isolation succeeded above, so a failed mode
|
||||
// activation must roll the cleared layers back instead of stranding the
|
||||
// user in a half-entered mode with an orphaned snapshot.
|
||||
const entryLayerId =
|
||||
mode === 'flights' ? 'military-awareness' : 'rocket-launches';
|
||||
if (mode === 'flights') {
|
||||
this._dataManager.setLayerParams('military-awareness', {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
let activated = false;
|
||||
let activationError = null;
|
||||
let activationIntent = null;
|
||||
let terminalIntentOutcome = null;
|
||||
try {
|
||||
activationIntent = this._dataManager._setEnabledWithIntent(
|
||||
entryLayerId,
|
||||
true,
|
||||
{ notificationToken, ...(signal ? { signal } : {}) },
|
||||
);
|
||||
this._contextModeEntryIntent = {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: activationIntent.intentEpoch,
|
||||
};
|
||||
activated = await activationIntent.promise;
|
||||
terminalIntentOutcome =
|
||||
await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
activationIntent.intentEpoch,
|
||||
);
|
||||
} catch (error) {
|
||||
activationError = error;
|
||||
}
|
||||
if (!isCurrent()) return false;
|
||||
let replacementIntent =
|
||||
mode === 'space-missions' &&
|
||||
this._contextModeReplacementIntent?.generation === generation &&
|
||||
this._contextModeReplacementIntent.layerId === entryLayerId
|
||||
? this._contextModeReplacementIntent
|
||||
: null;
|
||||
while (replacementIntent) {
|
||||
const outcome = await this._dataManager._waitForVisibilityIntent?.(
|
||||
entryLayerId,
|
||||
replacementIntent.intentEpoch,
|
||||
);
|
||||
terminalIntentOutcome = outcome;
|
||||
if (!isCurrent()) return false;
|
||||
const replacementOwnsMode =
|
||||
outcome?.intentEpoch === replacementIntent.intentEpoch &&
|
||||
outcome.enabled === true &&
|
||||
outcome.succeeded === true;
|
||||
if (replacementOwnsMode) {
|
||||
this._contextModeEntering = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
return true;
|
||||
}
|
||||
const successorEpoch =
|
||||
outcome?.cancellationReason === 'superseded' &&
|
||||
outcome.successorEnabled === true &&
|
||||
Number.isInteger(outcome.successorIntentEpoch) &&
|
||||
outcome.successorIntentEpoch > replacementIntent.intentEpoch
|
||||
? outcome.successorIntentEpoch
|
||||
: null;
|
||||
replacementIntent =
|
||||
successorEpoch === null
|
||||
? null
|
||||
: {
|
||||
generation,
|
||||
layerId: entryLayerId,
|
||||
intentEpoch: successorEpoch,
|
||||
};
|
||||
}
|
||||
if (
|
||||
activationError ||
|
||||
activated === false ||
|
||||
!this._dataManager.isEnabled(entryLayerId)
|
||||
) {
|
||||
const cancelledAndSettled =
|
||||
terminalIntentOutcome?.succeeded === false &&
|
||||
['caller-abort', 'resource-abort', 'superseded'].includes(
|
||||
terminalIntentOutcome.cancellationReason,
|
||||
);
|
||||
let transitionError = null;
|
||||
if (!cancelledAndSettled) {
|
||||
transitionError =
|
||||
activationError instanceof Error
|
||||
? activationError
|
||||
: new Error(`Context activation failed for: ${entryLayerId}`);
|
||||
transitionError.failedLayerIds = [
|
||||
...new Set([...(transitionError.failedLayerIds || []), entryLayerId]),
|
||||
];
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...transitionError.failedLayerIds,
|
||||
];
|
||||
console.warn(
|
||||
`[Context] ${mode} activation failed; restoring previous layers`,
|
||||
activationError || 'not enabled',
|
||||
);
|
||||
}
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({
|
||||
excludeLayerIds: [entryLayerId],
|
||||
notificationToken,
|
||||
});
|
||||
} catch (restoreError) {
|
||||
transitionError = mergeContextTransitionErrors(
|
||||
transitionError,
|
||||
restoreError,
|
||||
);
|
||||
this._contextTransitionFailedLayerIds = [
|
||||
...(transitionError?.failedLayerIds || []),
|
||||
];
|
||||
throw transitionError;
|
||||
}
|
||||
// `null` means the requested entry was cancelled and its exact rollback
|
||||
// completed. The action wrapper treats that as a silent non-commit,
|
||||
// while callers still require literal `true` before expanding Context.
|
||||
return cancelledAndSettled ? null : false;
|
||||
}
|
||||
this._contextModeEntryIntent = null;
|
||||
return true;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function _deactivateContextForLayerChange({
|
||||
notificationToken = null,
|
||||
} = {}) {
|
||||
this._contextModeGeneration += 1;
|
||||
this._contextModeChanging = true;
|
||||
this._contextMode = null;
|
||||
this._contextModeEntryIntent = null;
|
||||
this._contextModeReplacementIntent = null;
|
||||
if (this.cockpitView?.active)
|
||||
this.cockpitView.exit({ restoreTracking: false });
|
||||
this._syncContextModeButtons();
|
||||
try {
|
||||
await this._restoreContextSession({ notificationToken });
|
||||
} finally {
|
||||
this._contextModeChanging = false;
|
||||
this._syncContextModeButtons();
|
||||
}
|
||||
}
|
||||
|
||||
export async function _clearLayersOutsideContextMode(
|
||||
mode = null,
|
||||
{ notificationToken = null, signal = null } = {},
|
||||
) {
|
||||
const allowed = contextAllowedLayerIds(mode);
|
||||
const pending = [];
|
||||
for (const [layerId] of this._dataManager.layers || []) {
|
||||
// Effective visibility: a disallowed layer still mid-ENABLING must be
|
||||
// isolated too, or it settles ON inside the exclusive mode.
|
||||
if (
|
||||
!allowed.has(layerId) &&
|
||||
this._dataManager.isEffectivelyEnabled(layerId)
|
||||
) {
|
||||
pending.push({
|
||||
layerId,
|
||||
transition: this._dataManager.setEnabled(layerId, false, {
|
||||
notificationToken,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(
|
||||
pending.map(({ transition }) => transition),
|
||||
);
|
||||
const failed = pending
|
||||
.filter(
|
||||
({ layerId }, index) =>
|
||||
results[index] === false || this._dataManager.isEnabled(layerId),
|
||||
)
|
||||
.map(({ layerId }) => layerId);
|
||||
if (failed.length > 0) {
|
||||
const error = new Error(
|
||||
`Context isolation failed for: ${failed.join(', ')}`,
|
||||
);
|
||||
error.failedLayerIds = failed;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// The CCTV "ACQUIRING FRAME" caption lives in CSS `content:` and the
|
||||
// i18n scanner (scripts/i18n.mjs) never reads CSS, so es/ru show it in English.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STYLES = path.join(HERE, 'styles');
|
||||
const LOCALES = path.join(HERE, '..', 'i18n', 'locales');
|
||||
// cyber.css carries decorative theme strings (LOC / GRID, SYS 06 // ...);
|
||||
// street-level.css ' · ESC' is a key cap. Neither is UI copy.
|
||||
const DECORATIVE = new Set(['cyber.css', 'street-level.css']);
|
||||
|
||||
test('no user-visible text in CSS content: outside decorative themes', () => {
|
||||
const hits = [];
|
||||
for (const file of readdirSync(STYLES)) {
|
||||
if (!file.endsWith('.css') || DECORATIVE.has(file)) continue;
|
||||
const css = readFileSync(path.join(STYLES, file), 'utf8');
|
||||
css.split('\n').forEach((line, i) => {
|
||||
if (/^\s*content:\s*['"][^'"]*[A-Za-z]{2,}/.test(line))
|
||||
hits.push(`${file}:${i + 1} ${line.trim()}`);
|
||||
});
|
||||
}
|
||||
assert.deepEqual(hits, []);
|
||||
});
|
||||
|
||||
test('the CCTV acquisition caption has es and ru translations', () => {
|
||||
for (const lang of ['es', 'ru']) {
|
||||
const dict = JSON.parse(readFileSync(path.join(LOCALES, `${lang}.json`), 'utf8'));
|
||||
assert.ok('ACQUIRING FRAME' in dict, `${lang}.json lacks "ACQUIRING FRAME"`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
isCyberSonarEnabled,
|
||||
isCyberSonarActive,
|
||||
isCyberContactSonarActive,
|
||||
readCyberSonarSettings,
|
||||
} from '../cyberSonar.js';
|
||||
|
||||
const SETTING_FIELDS = Object.freeze({
|
||||
rings: ['rings', 3, 12],
|
||||
rangePct: ['range', 60, 120],
|
||||
intensityPct: ['intensity', 0, 100],
|
||||
opacityPct: ['opacity', 35, 100],
|
||||
sectorDeg: ['sector', 8, 60],
|
||||
});
|
||||
|
||||
/**
|
||||
* Read configuration separately from whether map effects can currently run.
|
||||
* @param {Document} [doc]
|
||||
* @param {{hudActive?: boolean}} [state] The HUD's visibility from IntelHUD,
|
||||
* which the view renders only on its next update.
|
||||
*/
|
||||
export function getCyberSonarControlState(
|
||||
doc = globalThis.document,
|
||||
{ hudActive } = {},
|
||||
) {
|
||||
const root = doc?.documentElement;
|
||||
const settings = readCyberSonarSettings(root);
|
||||
const options = {
|
||||
root,
|
||||
body: doc?.body,
|
||||
hud: doc?.getElementById?.('intel-hud') ?? null,
|
||||
hudActive,
|
||||
};
|
||||
return {
|
||||
enabled: isCyberSonarEnabled(root),
|
||||
rings: settings.rings,
|
||||
rangePct: settings.range,
|
||||
intensityPct: settings.intensity,
|
||||
opacityPct: settings.opacity,
|
||||
sectorDeg: settings.sector,
|
||||
cyberSelected: root?.dataset?.uiTheme === 'cyber',
|
||||
mapSweepActive: isCyberSonarActive(options),
|
||||
contactSweepActive:
|
||||
root?.dataset?.cyberSonarGpu === 'supported' &&
|
||||
isCyberContactSonarActive(options),
|
||||
contactRenderer: root?.dataset?.cyberSonarGpu ?? 'not-initialized',
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate every field before mutating through the same owner as the sliders. */
|
||||
export function setCyberSonarControls(
|
||||
owner,
|
||||
request,
|
||||
doc = globalThis.document,
|
||||
) {
|
||||
const fail = (error) => ({
|
||||
ok: false,
|
||||
error,
|
||||
sonar: getCyberSonarControlState(doc, { hudActive: owner?.hud?.visible }),
|
||||
});
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
return fail('Provide Cyber sonar settings as an object.');
|
||||
}
|
||||
const fields = Object.keys(request);
|
||||
if (!fields.length) return fail('Provide at least one Cyber sonar setting.');
|
||||
for (const field of fields) {
|
||||
const value = request[field];
|
||||
if (field === 'enabled') {
|
||||
if (typeof value !== 'boolean') return fail('enabled must be a boolean.');
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(SETTING_FIELDS, field)) {
|
||||
return fail('Unknown Cyber sonar setting.');
|
||||
}
|
||||
const [, min, max] = SETTING_FIELDS[field];
|
||||
if (!Number.isInteger(value) || value < min || value > max) {
|
||||
return fail(`${field} must be an integer from ${min} to ${max}.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
owner.hud?.getVariant?.() !== 'cyber' ||
|
||||
doc?.documentElement?.dataset?.uiTheme !== 'cyber'
|
||||
) {
|
||||
return fail('Cyber sonar controls require the Cyber HUD layout.');
|
||||
}
|
||||
for (const [field, [setting]] of Object.entries(SETTING_FIELDS)) {
|
||||
if (Object.hasOwn(request, field)) {
|
||||
owner._setCyberSonarSetting(setting, request[field]);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(request, 'enabled')) {
|
||||
owner._setCyberSonarEnabled(request.enabled);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
sonar: getCyberSonarControlState(doc, { hudActive: owner?.hud?.visible }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getCyberSonarControlState,
|
||||
setCyberSonarControls,
|
||||
} from './cyberSonarControls.js';
|
||||
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
function fixture(theme = 'cyber') {
|
||||
const root = {
|
||||
dataset: { uiTheme: theme, cyberSonarGpu: 'supported' },
|
||||
style: { setProperty() {} },
|
||||
};
|
||||
const flags = new Set();
|
||||
const hudFlags = new Set(['active']);
|
||||
const doc = {
|
||||
documentElement: root,
|
||||
body: { classList: { contains: (name) => flags.has(name) } },
|
||||
getElementById: () => ({
|
||||
classList: { contains: (name) => hudFlags.has(name) },
|
||||
}),
|
||||
};
|
||||
const calls = [];
|
||||
const owner = {
|
||||
hud: { getVariant: () => theme },
|
||||
_setCyberSonarSetting(name, value) {
|
||||
calls.push([name, value]);
|
||||
const suffix = name[0].toUpperCase() + name.slice(1);
|
||||
root.dataset['cyberSonar' + suffix] = String(value);
|
||||
},
|
||||
_setCyberSonarEnabled(value) {
|
||||
calls.push(['enabled', value]);
|
||||
root.dataset.cyberSonar = value ? 'on' : 'off';
|
||||
},
|
||||
};
|
||||
return { doc, owner, calls, flags, hudFlags, root };
|
||||
}
|
||||
|
||||
test('real runner and facade use visual settings, preserve visibility and return actual values', async () => {
|
||||
const f = fixture();
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
const renders = [];
|
||||
const visual = Object.create(VisualSettings.prototype);
|
||||
visual.services = { governorRequestRender: (reason) => renders.push(reason) };
|
||||
// No Display panel here: the controls' render is not under test.
|
||||
visual._publishDisplay = () => {};
|
||||
const owner = Object.create(StyleManager.prototype);
|
||||
owner._visualSettings = visual;
|
||||
Object.defineProperty(owner, 'hud', { value: f.owner.hud });
|
||||
const viewer = {
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: new EventTarget() },
|
||||
};
|
||||
try {
|
||||
const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} });
|
||||
const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.sonar.intensityPct, 73);
|
||||
assert.equal(result.sonar.sectorDeg, 9);
|
||||
assert.equal(result.sonar.enabled, false);
|
||||
assert.equal(f.root.dataset.uiTheme, 'cyber');
|
||||
assert.equal(f.hudFlags.has('active'), true);
|
||||
assert.equal(renders.length, 3);
|
||||
const before = getCyberSonarControlState();
|
||||
const invalid = await runner('set_cyber_sonar', { rings: 6, opacityPct: 0 });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.deepEqual(getCyberSonarControlState(), before);
|
||||
assert.equal(renders.length, 3);
|
||||
const zero = await runner('set_cyber_sonar', { intensityPct: 0 });
|
||||
assert.equal(zero.sonar.intensityPct, 0);
|
||||
assert.equal(zero.sonar.enabled, false);
|
||||
const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} });
|
||||
assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false);
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('native sonar sliders represent every accepted integer without browser snapping', () => {
|
||||
const html = readFileSync(new URL('./views/panels/DisplayPanel.vue', import.meta.url), 'utf8');
|
||||
for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) {
|
||||
assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`));
|
||||
}
|
||||
});
|
||||
|
||||
test('sonar action is available to both shared and server voice schemas', () => {
|
||||
for (const tools of [GEV_ACTION_SCHEMAS, GEV_REALTIME_TOOLS]) {
|
||||
const action = tools.find((tool) => tool.name === 'set_cyber_sonar');
|
||||
assert.ok(action);
|
||||
assert.equal(action.parameters.additionalProperties, false);
|
||||
assert.deepEqual(Object.keys(action.parameters.properties), [
|
||||
'enabled', 'rings', 'rangePct', 'intensityPct', 'opacityPct', 'sectorDeg',
|
||||
]);
|
||||
assert.equal(action.parameters.properties.intensityPct.minimum, 0);
|
||||
assert.equal(action.parameters.properties.opacityPct.minimum, 35);
|
||||
}
|
||||
});
|
||||
|
||||
test('maps all settings to the slider owner and reads actual state, including zero', () => {
|
||||
const f = fixture();
|
||||
const result = setCyberSonarControls(f.owner, {
|
||||
enabled: false, rings: 6, rangePct: 80,
|
||||
intensityPct: 0, opacityPct: 90, sectorDeg: 30,
|
||||
}, f.doc);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(f.calls, [
|
||||
['rings', 6], ['range', 80], ['intensity', 0],
|
||||
['opacity', 90], ['sector', 30], ['enabled', false],
|
||||
]);
|
||||
assert.equal(result.sonar.enabled, false);
|
||||
assert.equal(result.sonar.intensityPct, 0);
|
||||
assert.equal(result.sonar.contactSweepActive, false);
|
||||
assert.deepEqual(result.sonar, getCyberSonarControlState(f.doc));
|
||||
});
|
||||
|
||||
test('rejects the whole invalid request before any mutation', () => {
|
||||
for (const request of [
|
||||
{}, null, [], { enabled: 'false' }, { rings: 2 }, { rangePct: 121 },
|
||||
{ opacityPct: NaN }, { intensityPct: Infinity }, { sectorDeg: 8.5 },
|
||||
{ intensityPct: '50' }, { enabled: false, unknown: 1 },
|
||||
{ rings: 6, sectorDeg: 61 },
|
||||
]) {
|
||||
const f = fixture();
|
||||
assert.equal(setCyberSonarControls(f.owner, request, f.doc).ok, false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires Cyber and preserves off state and every omitted setting', () => {
|
||||
for (const theme of ['tactical', 'minimal']) {
|
||||
const f = fixture(theme);
|
||||
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
const f = fixture();
|
||||
f.root.dataset.cyberSonar = 'off';
|
||||
const before = getCyberSonarControlState(f.doc);
|
||||
const result = setCyberSonarControls(f.owner, { rings: 12 }, f.doc);
|
||||
assert.deepEqual(result.sonar, { ...before, rings: 12 });
|
||||
assert.deepEqual(f.calls, [['rings', 12]]);
|
||||
});
|
||||
|
||||
test('reports inactive map effects in Cockpit, hidden HUD, unsupported and uninitialized GPU', () => {
|
||||
const f = fixture();
|
||||
f.flags.add('cockpit-mode');
|
||||
assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, false);
|
||||
f.flags.clear();
|
||||
f.hudFlags.clear();
|
||||
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
|
||||
f.hudFlags.add('active');
|
||||
for (const value of ['unsupported', undefined]) {
|
||||
if (value === undefined) delete f.root.dataset.cyberSonarGpu;
|
||||
else f.root.dataset.cyberSonarGpu = value;
|
||||
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the HUD visibility its owner reports answers before the view re-renders', () => {
|
||||
const f = fixture();
|
||||
f.root.dataset.cyberSonar = 'on';
|
||||
// The HUD was just hidden: IntelHUD knows, the rendered class does not yet.
|
||||
assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, true);
|
||||
assert.equal(
|
||||
getCyberSonarControlState(f.doc, { hudActive: false }).mapSweepActive,
|
||||
false,
|
||||
);
|
||||
f.owner.hud.visible = false;
|
||||
const result = setCyberSonarControls(f.owner, { rings: 6 }, f.doc);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.sonar.mapSweepActive, false);
|
||||
assert.equal(result.sonar.contactSweepActive, false);
|
||||
});
|
||||
@@ -0,0 +1,913 @@
|
||||
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyHudUiTheme,
|
||||
CYBER_VISUAL_DEFAULTS,
|
||||
cyberVisualDefaultsForHudTransition,
|
||||
HUD_LAYOUTS,
|
||||
normalizeHudLayout,
|
||||
} from '../hudLayouts.js';
|
||||
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { STYLES } from './visualPresets.js';
|
||||
|
||||
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
|
||||
for (const file of ['panels/CctvPanel.vue', 'ContextView.vue']) {
|
||||
const markup = readFileSync(
|
||||
new URL(`./views/${file}`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// The body wears the shared scrollbar skin where Cyber makes it scroll.
|
||||
assert.match(
|
||||
markup,
|
||||
/class="cyber-panel-body ui-scroll" data-rail-scroller/,
|
||||
);
|
||||
assert.ok(
|
||||
// The header is RailPanel's; the body is its first content.
|
||||
markup.indexOf('<RailPanel ') > -1 &&
|
||||
markup.indexOf('<RailPanel ') <
|
||||
markup.indexOf('class="cyber-panel-body ui-scroll"'),
|
||||
);
|
||||
}
|
||||
const shared = readFileSync(
|
||||
new URL('./styles/controls.css', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cyber = readFileSync(
|
||||
new URL('./styles/cyber.css', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(shared, /\.cyber-panel-body\s*\{\s*display: contents;/);
|
||||
assert.match(
|
||||
cyber,
|
||||
/:root\[data-ui-theme='cyber'\] \.cyber-panel-body\s*\{[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain;/,
|
||||
);
|
||||
});
|
||||
|
||||
function styleOwner(t, initialVariant = 'cyber') {
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
const root = { dataset: {} };
|
||||
globalThis.document = { documentElement: root, querySelectorAll: () => [] };
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
t.after(() => Object.assign(globalThis, previous));
|
||||
let variant = initialVariant;
|
||||
const calls = [];
|
||||
const hud = {
|
||||
getVariant: () => variant,
|
||||
setVariant: (value) => {
|
||||
// Normalize like the real HUD so retired layouts fall back.
|
||||
variant = normalizeHudLayout(value);
|
||||
applyHudUiTheme(root, variant);
|
||||
},
|
||||
setMode: (value) => calls.push(['mode', value]),
|
||||
onStyleChange: (value) => calls.push(['style', value]),
|
||||
};
|
||||
hud.setVariant(initialVariant);
|
||||
const owner = Object.create(VisualSettings.prototype);
|
||||
Object.assign(owner, {
|
||||
activeStyle: 'normal',
|
||||
store: { publish() {} },
|
||||
readHud: () => hud,
|
||||
readShareLinks: () => ({ onStyleChange() {} }),
|
||||
_visualEffects: {
|
||||
stages: Object.fromEntries(
|
||||
Object.entries(STYLES).map(([name, config]) => [
|
||||
name,
|
||||
{ uniforms: { ...config.uniforms, intensity: 0 } },
|
||||
]),
|
||||
),
|
||||
},
|
||||
services: { governorRequestRender() {}, setDetectionStyle() {} },
|
||||
_setBloomEnabled: (value) => calls.push(['bloom', value]),
|
||||
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
|
||||
_applyDetectionPreset: (value) => calls.push(['detection', value]),
|
||||
_syncShareState: () => calls.push(['share', variant]),
|
||||
});
|
||||
for (const name of [
|
||||
'setCelestialRingEnabled',
|
||||
'_startTransition',
|
||||
'_publishActiveStyle',
|
||||
'_updateSliderPanel',
|
||||
'_updateHudButtonState',
|
||||
'_syncIrBoost',
|
||||
'_syncCockpitInheritedStyle',
|
||||
'_syncCyberSonarControl',
|
||||
'_scheduleAdaptivePanelLayout',
|
||||
'_setDetectionFade',
|
||||
// Intensities: the fixture has no post-process stages to drive.
|
||||
'_setBloomIntensity',
|
||||
'_setSharpenIntensity',
|
||||
])
|
||||
owner[name] = () => {};
|
||||
return { owner, hud, root, calls };
|
||||
}
|
||||
|
||||
test('visual styles retain Cyber through real preset application without dropping other defaults', (t) => {
|
||||
const { owner, hud, root, calls } = styleOwner(t);
|
||||
for (const style of [
|
||||
'surveillance',
|
||||
'normal',
|
||||
'noir',
|
||||
'anime',
|
||||
'thermal',
|
||||
'retro',
|
||||
]) {
|
||||
owner.setStyle(style);
|
||||
assert.equal(owner.activeStyle, style);
|
||||
assert.equal(hud.getVariant(), 'cyber');
|
||||
assert.equal(root.dataset.uiTheme, 'cyber');
|
||||
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
|
||||
}
|
||||
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
|
||||
assert.equal(calls.filter(([name]) => name === 'detection').length, 3);
|
||||
assert.equal(
|
||||
calls.filter(([name, value]) => name === 'bloom' && value === false).length,
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
calls.filter(([name, value]) => name === 'sharpen' && value === true)
|
||||
.length,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
|
||||
const { owner, hud, root } = styleOwner(t);
|
||||
for (const variant of ['tactical', 'minimal']) {
|
||||
owner._setHudVariant(variant);
|
||||
assert.equal(hud.getVariant(), variant);
|
||||
assert.equal(root.dataset.uiTheme, undefined);
|
||||
owner.setStyle('normal');
|
||||
owner.setStyle('surveillance');
|
||||
assert.equal(hud.getVariant(), 'tactical');
|
||||
}
|
||||
});
|
||||
|
||||
test('leaving Cyber without a preset choice restores the prior visual preset', (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
assert.equal(owner.activeStyle, 'noir');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
});
|
||||
|
||||
test('a preset explicitly selected in Cyber survives exit and resets for re-entry', (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
|
||||
// Reselecting the active mode must not erase the user's preset intent.
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner._preCyberStyle, 'noir');
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
|
||||
// A later untouched Cyber round trip restores the new pre-Cyber preset.
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._preCyberStyle, 'anime');
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
|
||||
owner.setStyle('thermal', { userInitiated: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
});
|
||||
|
||||
test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
|
||||
owner._setHudVariant('cyber');
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
test('Display and voice HUD routes preserve explicit Cyber entry and exit authority', async (t) => {
|
||||
const makeOwner = () => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
owner.setHudLayout = (...args) =>
|
||||
StyleManager.prototype.setHudLayout.call(owner, ...args);
|
||||
owner.getControlState = () => ({
|
||||
hud: { layout: hud.getVariant(), visible: true },
|
||||
});
|
||||
return { owner, hud };
|
||||
};
|
||||
|
||||
const display = makeOwner();
|
||||
// The Display panel's layout select and the dock's style buttons call
|
||||
// these actions through the display store.
|
||||
const store = useDisplayStore(createPinia());
|
||||
const token = store.connect({
|
||||
setHudLayout: (value) => display.owner.setHudLayout(value),
|
||||
setStyle: (value) => display.owner.setStyle(value, { userInitiated: true }),
|
||||
});
|
||||
store.act('setHudLayout', 'cyber');
|
||||
assert.equal(display.hud.getVariant(), 'cyber');
|
||||
assert.equal(display.owner.activeStyle, 'thermal');
|
||||
store.act('setStyle', 'anime');
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
store.act('setHudLayout', 'minimal');
|
||||
assert.equal(display.hud.getVariant(), 'minimal');
|
||||
assert.equal(display.owner.activeStyle, 'anime');
|
||||
store.detach(token);
|
||||
|
||||
const voice = makeOwner();
|
||||
const eventSource = { addEventListener() {}, removeEventListener() {} };
|
||||
const viewer = {
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: eventSource },
|
||||
};
|
||||
const runVoiceAction = createGevActionRunner({
|
||||
viewer,
|
||||
styleManager: voice.owner,
|
||||
dataManager: {},
|
||||
});
|
||||
const entered = await runVoiceAction('set_hud', { layout: 'cyber' });
|
||||
assert.equal(entered.ok, true);
|
||||
assert.equal(voice.hud.getVariant(), 'cyber');
|
||||
assert.equal(voice.owner.activeStyle, 'thermal');
|
||||
const selected = await runVoiceAction('set_visual_style', { style: 'anime' });
|
||||
assert.equal(selected.ok, true);
|
||||
assert.equal(voice.owner.activeStyle, 'anime');
|
||||
const exited = await runVoiceAction('set_hud', { layout: 'minimal' });
|
||||
assert.equal(exited.ok, true);
|
||||
assert.equal(voice.hud.getVariant(), 'minimal');
|
||||
assert.equal(voice.owner.activeStyle, 'anime');
|
||||
});
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||
let shown = null;
|
||||
owner.store = { publish: (view) => (shown = view.scope) };
|
||||
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
||||
assert.deepEqual(shown, { enabled: false, featherPct: 200 });
|
||||
for (const layout of ['minimal', 'cyber', 'tactical']) {
|
||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||
assert.equal(shown.enabled, false, layout);
|
||||
}
|
||||
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||
assert.equal(isScopeMaskEnabled(), true);
|
||||
assert.equal(getScopeMaskFeather(), 1.75);
|
||||
assert.deepEqual(shown, { enabled: true, featherPct: 175 });
|
||||
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||
assert.equal(getScopeMaskFeather(), 2);
|
||||
assert.deepEqual(shown, { enabled: true, featherPct: 200 });
|
||||
});
|
||||
|
||||
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
await owner.applyVisualState({ style: 'snow' });
|
||||
assert.equal(owner.activeStyle, 'normal');
|
||||
});
|
||||
|
||||
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
assert.equal(owner._cyberStyleUserSelected, true);
|
||||
|
||||
await owner.applyVisualState({ style: 'retro', hud: { variant: 'cyber' } });
|
||||
assert.equal(hud.getVariant(), 'cyber');
|
||||
assert.equal(owner.activeStyle, 'retro');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
owner._setHudVariant('minimal', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'retro');
|
||||
|
||||
owner.setStyle('surveillance');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner._preCyberStyle, 'surveillance');
|
||||
owner.setStyle('anime', { userInitiated: true });
|
||||
await owner.restoreShareState({
|
||||
style: 'thermal',
|
||||
hudVariant: 'cyber',
|
||||
});
|
||||
assert.equal(hud.getVariant(), 'cyber');
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
assert.equal(owner._cyberStyleUserSelected, false);
|
||||
owner._setHudVariant('tactical', { applyVisualDefaults: true });
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner._preCyberStyle, null);
|
||||
});
|
||||
|
||||
test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => {
|
||||
const { owner, hud } = styleOwner(t);
|
||||
await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } });
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
owner._setHudVariant('cyber');
|
||||
await owner.restoreShareState({
|
||||
style: 'surveillance',
|
||||
hudVariant: 'operator',
|
||||
});
|
||||
// A link saved with the retired Operator layout restores as Tactical.
|
||||
assert.equal(hud.getVariant(), 'tactical');
|
||||
await owner.restoreShareState({
|
||||
style: 'thermal',
|
||||
hudVariant: 'cyber',
|
||||
styleParams: { palette: 0.73 },
|
||||
});
|
||||
assert.equal(hud.getVariant(), 'cyber');
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
|
||||
owner._setHudVariant('tactical');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
|
||||
});
|
||||
|
||||
const read = (relative) =>
|
||||
readFileSync(new URL(relative, import.meta.url), 'utf8');
|
||||
|
||||
test('Cyber setup uses a compact toolbar icon and highlights the north-up letter', () => {
|
||||
const css = read('./styles/cyber.css');
|
||||
assert.match(
|
||||
css,
|
||||
/#top-center-actions #key-setup-chip\s*\{[^}]*inset: auto;[^}]*width: 36px;/,
|
||||
);
|
||||
assert.match(css, /#key-setup-chip\[hidden\]\s*\{\s*display: none;/);
|
||||
assert.match(
|
||||
css,
|
||||
/#north-up-view\[data-north-up='true'\]\s+\.camera-compass-n\s*\{[^}]*color: var\(--cyber-red-bright\)/,
|
||||
);
|
||||
});
|
||||
const cyberStyles = read('./styles/cyber.css');
|
||||
|
||||
// Source guards supplement rendered popup/hit-target checks. Inspect one rule
|
||||
// at a time so a safe declaration on a child cannot excuse a clipped ancestor.
|
||||
function cssRuleBody(styles, selector) {
|
||||
const match = styles.match(new RegExp(`${selector.source}\\s*\\{([^}]+)\\}`));
|
||||
assert.ok(match, `Missing CSS rule: ${selector.source}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test('Cyber Voice clips its decoration without clipping descendant help and errors', () => {
|
||||
const owner = cssRuleBody(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control/,
|
||||
);
|
||||
assert.match(owner, /isolation:\s*isolate;/);
|
||||
assert.match(owner, /overflow:\s*visible;/);
|
||||
assert.match(owner, /clip-path:\s*none;/);
|
||||
|
||||
const decoration = cssRuleBody(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\] #command-dock > #gev-voice-control::before/,
|
||||
);
|
||||
assert.match(decoration, /clip-path:\s*polygon\(/);
|
||||
assert.match(decoration, /pointer-events:\s*none;/);
|
||||
assert.match(decoration, /inset:\s*0;/);
|
||||
assert.match(decoration, /z-index:\s*-1;/);
|
||||
});
|
||||
|
||||
test('Cyber dock pins escape the tray mask and its decoration overrides the legacy arrow geometry', () => {
|
||||
const owner = cssRuleBody(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\] #command-dock \.dock-popover-content/,
|
||||
);
|
||||
assert.match(owner, /clip-path:\s*none;/);
|
||||
assert.match(owner, /isolation:\s*isolate;/);
|
||||
|
||||
// Including the tray owners beats #control-panel's more specific old
|
||||
// ::after arrow positioning. Otherwise left:auto can collapse the frame.
|
||||
const decoration = cssRuleBody(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\]\s+#command-dock\s+:is\(#location-bar, #control-panel\)\s+\.dock-popover-content::after/,
|
||||
);
|
||||
assert.match(decoration, /inset:\s*0;/);
|
||||
assert.match(decoration, /width:\s*auto;/);
|
||||
assert.match(decoration, /height:\s*auto;/);
|
||||
assert.match(decoration, /transform:\s*none;/);
|
||||
assert.match(decoration, /clip-path:\s*var\(--cyber-panel-frame\);/);
|
||||
assert.match(decoration, /pointer-events:\s*none;/);
|
||||
});
|
||||
|
||||
test('Cyber lower-left telemetry tracks the credit anchor with clearance for its logo row', () => {
|
||||
const telemetry = cssRuleBody(
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-bottom-left/,
|
||||
);
|
||||
const offset = telemetry.match(/bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/);
|
||||
assert.ok(offset, 'telemetry must retain the attribution viewport anchor');
|
||||
const credits = read('./styles/command-dock-trays.css');
|
||||
const creditOffsets = [
|
||||
...credits.matchAll(
|
||||
/#cesium-credits\s*\{[^}]*bottom:\s*calc\(2vh \+ ([\d.]+)rem\);/g,
|
||||
),
|
||||
];
|
||||
assert.ok(creditOffsets.length > 0);
|
||||
for (const credit of creditOffsets) {
|
||||
assert.ok(
|
||||
Number(offset[1]) - Number(credit[1]) >= 2.75,
|
||||
'reserve the 28px logo row, baseline descent and 12px clearance at the 16px root size',
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(cyberStyles, /\.hud-bottom-left\s*\{\s*bottom:\s*60px;/);
|
||||
});
|
||||
|
||||
test('shared panel hooks affect only opted-in rail surfaces without overriding disclosure and concealment', () => {
|
||||
const styles = read('./styles/panel-surfaces.css');
|
||||
const rail = cssRuleBody(
|
||||
styles,
|
||||
/:is\(#left-panel-stack, #right-context-rail\)\s+> \[data-panel-id\]:has\(> \[data-panel-surface\]\)/,
|
||||
);
|
||||
assert.match(rail, /pointer-events:\s*auto;/);
|
||||
assert.doesNotMatch(rail, /(?:display|visibility):/);
|
||||
assert.doesNotMatch(
|
||||
styles,
|
||||
/:is\(#left-panel-stack, #right-context-rail\)\s*> \[data-panel-id\]\s*\{/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\[data-panel-surface\]:not\(\[hidden\]\):not\(dialog:not\(\[open\]\)\)\s*\{\s*display: flex;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/dialog\[data-panel-surface\]:not\(\[open\]\)\s*\{\s*display: none !important;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/body:is\(\.ui-clean-view, \.recording-mode\)[^{]+\{[^}]*visibility: hidden !important;[^}]*pointer-events: none !important;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\]\s+:is\(\s*\.panel-collapse-btn,\s*body\.cockpit-mode \.cockpit-utility-glyph\s*\):focus-visible\s*\{[^}]*outline: 2px solid var\(--cyber-red-bright\) !important;[^}]*outline-offset: -3px;[^}]*box-shadow: none;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\]\s+:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\):is\(\s*:hover,\s*:focus-visible\s*\)\s*\{[^}]*border-color: var\(--cyber-red-bright\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber Radio retains upstream nested Context ownership', () => {
|
||||
assert.doesNotMatch(cyberStyles, /#right-context-rail\s*>\s*#radio-panel/);
|
||||
});
|
||||
|
||||
test('Cyber cockpit launchers share Data Layers frame and expand-control treatment', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\)/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode \.cockpit-utility-launcher \{[^}]*width: var\(--cyber-panel-width\);[^}]*min-height: 60px;[^}]*clip-path: var\(--cyber-panel-frame\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-glyph::after \{\s*content: '\+';/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-glyph\[aria-expanded='true'\]::after \{\s*content: '−';/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded\s+\.cockpit-utility-launcher \{[^}]*width: 100%;[^}]*clip-path: none;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber side panels share one width and one framed surface material', () => {
|
||||
assert.match(cyberStyles, /--cyber-panel-width: 272px;/);
|
||||
assert.match(cyberStyles, /--cyber-right-panel-width: 312px;/);
|
||||
assert.match(cyberStyles, /--cyber-panel-collapsed-width: 200px;/);
|
||||
assert.match(cyberStyles, /--cyber-panel-control-size: 30px;/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--cyber-panel-surface: rgba\(18, 24, 27, 0\.92\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded \{[\s\S]*?width: var\(--cyber-right-panel-width\);[\s\S]*?background-color: var\(--cyber-panel-surface\);[\s\S]*?background-image: var\(--cyber-panel-surface-detail\);[\s\S]*?clip-path: var\(--cyber-panel-frame\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded::after \{[\s\S]*?width: 36px;[\s\S]*?border-top: 1px solid rgba\(225, 102, 102, 0\.84\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#global-context-panel #radio-panel\.collapsed \{\s*width: 100%;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-inline-end: 8px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#right-context-rail[\s\S]*?:is\(\s*#cctv-panel,\s*#weather-panel,\s*#recent-imagery-panel,\s*#street-level-panel,\s*#global-context-panel\s*\)\.collapsed[\s\S]*?> \.panel-header \{[\s\S]*?height: 48px;[\s\S]*?padding: 0 10px 0 14px;/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
|
||||
'expanded Cockpit utilities must retain Display-then-Radio DOM order',
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
|
||||
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'minimal', 'cyber']);
|
||||
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
|
||||
assert.equal(normalizeHudLayout('operator'), 'tactical');
|
||||
assert.equal(normalizeHudLayout('unknown'), 'tactical');
|
||||
});
|
||||
|
||||
test('the Cyber HUD owns the global shell skin and leaving it restores defaults', () => {
|
||||
const root = { dataset: { unrelated: 'preserved' } };
|
||||
assert.equal(applyHudUiTheme(root, 'cyber'), 'cyber');
|
||||
assert.equal(root.dataset.uiTheme, 'cyber');
|
||||
assert.equal(applyHudUiTheme(root, 'minimal'), null);
|
||||
assert.equal(root.dataset.uiTheme, undefined);
|
||||
assert.equal(root.dataset.unrelated, 'preserved');
|
||||
});
|
||||
|
||||
test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () => {
|
||||
assert.deepEqual(CYBER_VISUAL_DEFAULTS, {
|
||||
style: 'thermal',
|
||||
ironbow: 0.42,
|
||||
});
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('tactical', 'cyber', {
|
||||
explicit: true,
|
||||
}),
|
||||
CYBER_VISUAL_DEFAULTS,
|
||||
);
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('cyber', 'cyber', {
|
||||
explicit: true,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('cyber', 'minimal', {
|
||||
explicit: true,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber visual defaults update the style, thermal palette and render state', () => {
|
||||
const calls = [];
|
||||
const owner = {
|
||||
stages: { thermal: { uniforms: { palette: 0 } } },
|
||||
services: {
|
||||
governorRequestRender: (reason) => calls.push(['render', reason]),
|
||||
},
|
||||
setStyle: (style, options) => calls.push(['style', style, options]),
|
||||
_updateSliderPanel: (style, options) =>
|
||||
calls.push(['sliders', style, options]),
|
||||
};
|
||||
|
||||
VisualSettings.prototype._applyCyberVisualDefaults.call(
|
||||
owner,
|
||||
CYBER_VISUAL_DEFAULTS,
|
||||
);
|
||||
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
|
||||
assert.deepEqual(calls, [
|
||||
[
|
||||
'style',
|
||||
'thermal',
|
||||
{
|
||||
applyPreset: false,
|
||||
revealParameters: false,
|
||||
preserveCyberRestore: true,
|
||||
},
|
||||
],
|
||||
['sliders', 'thermal', { reveal: false }],
|
||||
['render', 'cyber-visual-defaults'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
|
||||
const display = read('./views/panels/DisplayPanel.vue');
|
||||
const stylesheet = read('../../style.css');
|
||||
const rightRail = read('./rightPanelRail.js');
|
||||
const hud = read('../hud.js');
|
||||
const hudView = read('./views/IntelHudView.vue');
|
||||
const detection = read('../data/detection.js');
|
||||
const civilRendering = read('../layers/flights/rendering.js');
|
||||
const militaryRendering = read('../layers/military/rendering.js');
|
||||
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
|
||||
|
||||
assert.match(display, /<option value="cyber">\{\{ t\('Cyber'\) \}\}<\/option>/);
|
||||
assert.match(display, /id="cyber-sonar-toggle"/);
|
||||
assert.match(
|
||||
display,
|
||||
/id="cyber-sonar-opacity"[^>]*:model-value="sonar\?\.opacity \?\? 84" :aria-label="t\('Cyber contact and label opacity'\)"/,
|
||||
);
|
||||
assert.match(display, /id="cyber-sonar-sector"/);
|
||||
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
|
||||
assert.match(stylesheet, /@import '\.\/src\/ui\/styles\/cyber\.css';\s*$/);
|
||||
assert.match(cyberStyles, /:root\[data-ui-theme='cyber'\]/);
|
||||
assert.match(cyberStyles, /\.material-symbols-outlined/);
|
||||
assert.match(cyberStyles, /\.pp-label/);
|
||||
assert.match(cyberStyles, /#intel-hud\[data-variant='cyber'\]/);
|
||||
assert.match(cyberStyles, /#left-panel-stack/);
|
||||
assert.match(cyberStyles, /#right-context-rail/);
|
||||
assert.match(cyberStyles, /#command-dock/);
|
||||
assert.match(cyberStyles, /--cyber-panel-frame: polygon/);
|
||||
assert.match(cyberStyles, /\.data-panel-inner,/);
|
||||
assert.match(cyberStyles, /#pp-toggles\.collapsed > \.pp-header-row/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/,
|
||||
);
|
||||
assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/);
|
||||
assert.match(hudView, /class="hud-sonar" aria-hidden="true"/);
|
||||
assert.match(cyberStyles, /\.hud-sonar::before/);
|
||||
assert.match(cyberStyles, /\.hud-sonar::after/);
|
||||
assert.match(cyberStyles, /@keyframes cyber-sonar-sweep/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/animation-delay:\s*var\(--cyber-sonar-phase-delay, 0ms\)/,
|
||||
);
|
||||
assert.doesNotMatch(cyberStyles, /--cyber-sonar-angle/);
|
||||
assert.match(cyberStyles, /prefers-reduced-motion: reduce/);
|
||||
assert.match(detection, /candidate\.sonarFactor \?\? 1/);
|
||||
assert.match(detection, /sonarActive && !obj\.skipLabel/);
|
||||
assert.match(detection, /const bracketAlpha = admissionAlpha \* sonarFactor/);
|
||||
assert.match(detection, /sonarSampler\.label\(sonarFactor\)/);
|
||||
assert.match(detection, /createCyberSonarSampler\(width, height, now\)/);
|
||||
assert.match(cyberStyles, /data-cyber-sonar='off'/);
|
||||
assert.match(detection, /governorRequestRender\('cyber-sonar-cadence'\)/);
|
||||
assert.match(
|
||||
detection,
|
||||
/const animatingCount = countAnimatingRenderEntries\(renderEntries\);/,
|
||||
);
|
||||
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
|
||||
assert.match(
|
||||
civilRendering,
|
||||
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
||||
);
|
||||
assert.match(
|
||||
militaryRendering,
|
||||
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#title-bar \.title-logo #globe \{[\s\S]*?fill: var\(--cyber-red\);[\s\S]*?stroke: var\(--cyber-red-bright\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /#title-bar \.title-logo #globe_cage/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#top-center-actions button\s*\{[\s\S]*?border-radius:\s*0;[\s\S]*?clip-path:\s*polygon\(\s*29% 0,\s*71% 0,[\s\S]*?0 29%\s*\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /#top-center-actions button::before/);
|
||||
assert.match(cyberStyles, /\.hud-top-left/);
|
||||
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
|
||||
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
|
||||
assert.doesNotMatch(cyberStyles, /\.hud-top-bar/);
|
||||
// Cyber shares the measured rail/credit keep-out rather than overriding it.
|
||||
assert.doesNotMatch(
|
||||
cyberStyles,
|
||||
/#right-context-rail\s*\{[^}]*\b(?:top|bottom|height|max-height):/,
|
||||
);
|
||||
assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--cyber-left-rail-top: max\([\s\S]*?220px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 120px\)/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;[\s\S]*?bottom: calc\(2vh \+ 13rem\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
|
||||
);
|
||||
assert.match(
|
||||
rightRail,
|
||||
/const leftStackTop = settledLeftRailTop\([\s\S]*?const alignedTop = Number\.isFinite\(leftStackTop\)[\s\S]*?baseTop: alignedTop,/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/clip-path: polygon\([\s\S]*?12px 0,[\s\S]*?0 12px/,
|
||||
);
|
||||
assert.match(cyberStyles, /\.hud-corner::before/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--cyber-telemetry-fill: rgba\(13, 20, 23, 0\.985\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /var\(--cyber-telemetry-fill\)/);
|
||||
assert.match(cyberStyles, />\s*\.panel-header\s+\.panel-title::before/);
|
||||
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
|
||||
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
|
||||
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
|
||||
// The base standby is borderless plain text, so the focus card must name its
|
||||
// own frame width and centring rather than inherit them.
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.context-mode-standby:not\(\[hidden\]\) \{[^}]*place-content: center;[^}]*border: 1px solid[^}]*text-align: center;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\] \.global-context-modes \{[^}]*border: 0;/,
|
||||
);
|
||||
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
|
||||
assert.match(cyberStyles, /flex: 1 1 auto;/);
|
||||
assert.match(cyberStyles, /#left-panel-stack:has/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/inset-inline-start: calc\(54px \+ var\(--panel-rail-clearance\)\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /right: calc\(51px \+ var\(--panel-rail-clearance\)\);/);
|
||||
// Each side's edge line shows only beside an open panel.
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-start-color:/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#right-context-rail:has\(> \[data-panel-id\]:not\(\.collapsed, \[hidden\]\)\) \{\s*border-inline-end-color:/,
|
||||
);
|
||||
assert.match(cyberStyles, /bottom: calc\(2vh \+ 13rem\);/);
|
||||
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
|
||||
assert.match(cyberStyles, /data-layer-id='satellites'/);
|
||||
assert.match(cyberStyles, /#search-toggle::before/);
|
||||
assert.match(cyberStyles, /VISUAL MATRIX \/\/ 03/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode #cockpit-hud/);
|
||||
assert.match(cyberStyles, /TARGET SCANNING \/\/ LIVE VECTOR/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #global-loading-status \{[\s\S]*?top: 60px;[\s\S]*?z-index: 146;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #global-loading-detail \{[\s\S]*?color: var\(--cockpit-accent\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-visor-shell::before/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-scan-array/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-side-arcs/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-ticks/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-core/);
|
||||
assert.match(cyberStyles, /width: min\(48vw, 60vh\);/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-context-window/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-signal-window/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode #intel-hud\.active/);
|
||||
});
|
||||
|
||||
test('Cyber voice telemetry renders a live scan scope without replacing audio ownership', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#gev-voice-control\s*\{[\s\S]*?--cyber-voice-wave:/,
|
||||
);
|
||||
assert.match(cyberStyles, /\.gev-voice-visualizer::before/);
|
||||
assert.match(cyberStyles, /\.gev-voice-visualizer::after/);
|
||||
assert.match(cyberStyles, /@keyframes gev-cyber-voice-scan/);
|
||||
assert.match(cyberStyles, /@keyframes gev-cyber-voice-lock/);
|
||||
assert.match(cyberStyles, /\[data-speaker='user'\]/);
|
||||
assert.match(cyberStyles, /\[data-speaker='ai'\]/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/prefers-reduced-motion: reduce[\s\S]*?gev-voice-visualizer::after[\s\S]*?animation: none/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber Cockpit Display uses the released briefing lane instead of the legacy viewport cap', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode\s+#cockpit-display-panel\s*\{[\s\S]*?max-height:\s*calc\(var\(--cockpit-utility-expanded-max-height, 72vh\) - 62px\)/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#cockpit-hud:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?\.cockpit-signal-window\[data-collapsed='true'\][\s\S]*?bottom: 80px;[\s\S]*?visibility:\s*visible/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body:not\(\.cockpit-mode\)\s+#left-panel-stack:has\([\s\S]*?max-height:\s*none;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers frame', () => {
|
||||
const desktop = cyberStyles.slice(
|
||||
cyberStyles.indexOf('@media (min-width: 961px) and (min-height: 650px)'),
|
||||
cyberStyles.indexOf('@media (max-width: 720px)'),
|
||||
);
|
||||
assert.match(
|
||||
desktop,
|
||||
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 9px 18px 9px 24px;/,
|
||||
);
|
||||
assert.match(
|
||||
desktop,
|
||||
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher::after\s*\{\s*bottom: 6px;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber right panels preserve intrinsic sizing and the shared rail allocation', () => {
|
||||
assert.doesNotMatch(
|
||||
cyberStyles.slice(cyberStyles.indexOf(":root[data-ui-theme='cyber'] {")),
|
||||
/#right-context-rail\s*>[^{}]+\{[^{}]*\b(?:flex|height|max-height):/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber panels retain clean edges without the added footer band', () => {
|
||||
assert.doesNotMatch(cyberStyles, /--cyber-panel-foot-/);
|
||||
assert.doesNotMatch(cyberStyles, /border-bottom: 24px solid transparent/);
|
||||
});
|
||||
|
||||
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
|
||||
const template = readFileSync(
|
||||
new URL('./views/panels/DisplayPanel.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /id="cyber-sonar-toggle"/);
|
||||
assert.doesNotMatch(template, /data-sonar-mode|cyber-sonar-mode-row/);
|
||||
assert.doesNotMatch(cyberStyles, /cyber-scene-dimmer|data-cyber-sonar-mode/);
|
||||
});
|
||||
|
||||
test('Cyber Display uses a red title without a panel-specific center tab', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#pp-toggles \.pp-header-label\s*\{\s*color: var\(--cyber-red-bright\);/,
|
||||
);
|
||||
assert.doesNotMatch(cyberStyles, /#pp-toggles[^,{]*::after\s*\{/);
|
||||
assert.match(cyberStyles, /isolation: isolate;\s*clip-path: none;/);
|
||||
assert.match(cyberStyles, /#pp-toggles:not\(\.collapsed\)::before/);
|
||||
});
|
||||
|
||||
test('Cyber omits only the fictional classification and system header lines', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-classification,\s*#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-system\s*\{\s*display: none;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||
export class DisplayBindings {
|
||||
constructor({
|
||||
viewer,
|
||||
services,
|
||||
elements,
|
||||
operations,
|
||||
readState,
|
||||
claimDetection,
|
||||
displayStore,
|
||||
}) {
|
||||
Object.assign(
|
||||
this,
|
||||
{ viewer, services, readState, claimDetection, displayStore },
|
||||
elements,
|
||||
operations,
|
||||
);
|
||||
}
|
||||
get shareLinkManager() {
|
||||
return this.readState().shareLinkManager;
|
||||
}
|
||||
get hud() {
|
||||
return this.readState().hud;
|
||||
}
|
||||
get bloomEnabled() {
|
||||
return this.readState().bloomEnabled;
|
||||
}
|
||||
get sharpenEnabled() {
|
||||
return this.readState().sharpenEnabled;
|
||||
}
|
||||
get celestialRing() {
|
||||
return this.readState().celestialRing;
|
||||
}
|
||||
get celestialRingEnabled() {
|
||||
return this.readState().celestialRingEnabled;
|
||||
}
|
||||
get _models3dEnabled() {
|
||||
return this.readState()._models3dEnabled;
|
||||
}
|
||||
_initUI() {
|
||||
const { cycleDetectionMode, isScopeMaskEnabled } = this.services;
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._frameRateMonitor?.destroy();
|
||||
this._frameRateMonitor = createFrameRateMonitor({
|
||||
viewer: this.viewer,
|
||||
documentRef: document,
|
||||
});
|
||||
this._applicationShortcuts = bindApplicationShortcuts({
|
||||
documentRef: document,
|
||||
searchInput: this._locationSearch,
|
||||
actions: {
|
||||
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
|
||||
dismissSearch: () => this._closeLocationSearch?.({ ifOpen: true }),
|
||||
toggleHud: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleOrbit: () => this._toggleOrbit(),
|
||||
toggleCleanView: () => this.toggleCleanView(),
|
||||
toggleLayers: () =>
|
||||
document.getElementById('data-panel').classList.toggle('active'),
|
||||
cycleDetection: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleCctv: () => this._toggleCctvEnabled(),
|
||||
},
|
||||
});
|
||||
|
||||
this._setScope({ enabled: isScopeMaskEnabled() }, { apply: false });
|
||||
// The Display controls (and the dock's style buttons) answer through
|
||||
// the display store; DisplayPanel's events call these actions.
|
||||
this._detachDisplayControls?.();
|
||||
const store = this.displayStore ?? useDisplayStore(applicationPinia());
|
||||
const token = store.connect({
|
||||
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
|
||||
toggleBloom: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomEnabled(!this.bloomEnabled);
|
||||
},
|
||||
setBloomIntensity: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setBloomIntensity(value);
|
||||
},
|
||||
toggleSharpen: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setScope({ enabled });
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setScope({ featherPct: value });
|
||||
this._syncShareState();
|
||||
},
|
||||
setSharpenIntensity: (pct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenIntensity(pct);
|
||||
},
|
||||
setHudLayout: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setHudVariant(value, { applyVisualDefaults: true });
|
||||
},
|
||||
toggleCleanView: () => this.toggleCleanView(),
|
||||
exitCleanView: () => this.toggleCleanView(false),
|
||||
setDensity: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
this._setDetectionDensity(value);
|
||||
this._syncShareState();
|
||||
},
|
||||
setAllocation: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
this._setDetectionAllocation(value);
|
||||
},
|
||||
setFade: (fadePct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setDetectionFade({ fadePct });
|
||||
this._syncShareState();
|
||||
},
|
||||
setOutsideOpacity: (outsideOpacityPct) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setDetectionFade({ outsideOpacityPct });
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleCelestial: () => {
|
||||
const ringIsVisible = !!this.celestialRing?.visible;
|
||||
if (!this.celestialRingEnabled || !ringIsVisible) {
|
||||
this.setCelestialRingEnabled(true, { focus: true });
|
||||
} else {
|
||||
this.setCelestialRingEnabled(false);
|
||||
}
|
||||
},
|
||||
toggleHud: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.hud.toggle();
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleSonar: () => this._setCyberSonarEnabled(),
|
||||
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
|
||||
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
|
||||
setSonarIntensity: (value) =>
|
||||
this._setCyberSonarSetting('intensity', value),
|
||||
setSonarOpacity: (value) => this._setCyberSonarSetting('opacity', value),
|
||||
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
|
||||
cycleDetection: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
cycleDetectionMode();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleModels: () => {
|
||||
this._setModels3dEnabled(!this._models3dEnabled);
|
||||
this._syncModels3dModeRow();
|
||||
},
|
||||
setModelsMode: (mode) => this._setModels3dMode(mode),
|
||||
});
|
||||
this._detachDisplayControls = () => store.detach(token);
|
||||
}
|
||||
destroy() {
|
||||
this._applicationShortcuts?.destroy();
|
||||
this._applicationShortcuts = null;
|
||||
this._frameRateMonitor?.destroy();
|
||||
this._frameRateMonitor = null;
|
||||
this._detachDisplayControls?.();
|
||||
this._detachDisplayControls = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Keep a native Scope checkbox and feather availability in step with the
|
||||
* mask. The Display view renders both from VisualSettings now; this stays
|
||||
* exported from `./ui/display` for hosts that imported it.
|
||||
* @deprecated
|
||||
*/
|
||||
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||
if (toggle) toggle.checked = enabled;
|
||||
if (featherSlider) featherSlider.disabled = !enabled;
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
// src/ui/displayPanel.test.mjs — the Display panel renders bloom and sharpen
|
||||
// from what VisualSettings publishes, and shows its initial markup before.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import DisplayPanel from './views/panels/DisplayPanel.vue';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { initGeoFileImport } from './geoFileImport.js';
|
||||
import { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
|
||||
// Core's classes on an element, without the kit's `ui-` classes.
|
||||
const coreClasses = (el) =>
|
||||
[...el.classList].filter((name) => !name.startsWith('ui-')).join(' ');
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(DisplayPanel).use(pinia);
|
||||
app.mount(host);
|
||||
let mounted = true;
|
||||
const unmount = () => {
|
||||
if (mounted) app.unmount();
|
||||
mounted = false;
|
||||
};
|
||||
t.after(unmount);
|
||||
const $ = (id) => document.getElementById(id);
|
||||
return { store: useDisplayStore(pinia), $, unmount };
|
||||
}
|
||||
|
||||
/** A VisualSettings over stand-in effects, publishing to `store`. */
|
||||
function settings(store) {
|
||||
const owner = Object.create(VisualSettings.prototype);
|
||||
const effects = {
|
||||
bloomEnabled: false,
|
||||
sharpenEnabled: false,
|
||||
bloomIntensity: 0,
|
||||
sharpenIntensity: 0.49,
|
||||
applyBloomIntensity(value) {
|
||||
this.bloomIntensity = value;
|
||||
},
|
||||
applySharpenIntensity(value) {
|
||||
this.sharpenIntensity = value;
|
||||
},
|
||||
setBloomEnabled(on) {
|
||||
this.bloomEnabled = on;
|
||||
},
|
||||
setSharpenEnabled(on) {
|
||||
this.sharpenEnabled = on;
|
||||
},
|
||||
syncBloomEnabled() {},
|
||||
};
|
||||
const shares = [];
|
||||
Object.assign(owner, {
|
||||
store,
|
||||
_visualEffects: effects,
|
||||
_sharpenPct: 49,
|
||||
services: { governorRequestRender() {} },
|
||||
_syncShareState: () => shares.push(true),
|
||||
_layoutRightPanels() {},
|
||||
});
|
||||
return { owner, effects, shares };
|
||||
}
|
||||
|
||||
test('before VisualSettings renders, the panel shows its initial markup', (t) => {
|
||||
const { $ } = fixture(t);
|
||||
assert.equal($('bloom-toggle').className, 'pp-toggle-btn');
|
||||
assert.equal($('bloom-slider-row').className, 'pp-slider-row');
|
||||
assert.equal($('bloom-intensity-slider').value, '0');
|
||||
assert.equal($('bloom-intensity-value').textContent, '0%');
|
||||
assert.equal($('sharpen-intensity-slider').value, '49');
|
||||
assert.equal($('sharpen-intensity-value').textContent, '49%');
|
||||
});
|
||||
|
||||
test('bloom and sharpen render their state, slider and readout', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner, effects, shares } = settings(store);
|
||||
|
||||
owner._setBloomEnabled(true);
|
||||
owner._setBloomIntensity(140.4);
|
||||
await nextTick();
|
||||
assert.equal($('bloom-toggle').classList.contains('active'), true);
|
||||
assert.equal($('bloom-slider-row').classList.contains('visible'), true);
|
||||
assert.equal($('bloom-intensity-slider').value, '140');
|
||||
assert.equal($('bloom-intensity-value').textContent, '140%');
|
||||
assert.equal(effects.bloomIntensity, 140);
|
||||
|
||||
owner._setSharpenEnabled(true);
|
||||
owner._setSharpenIntensity(130);
|
||||
await nextTick();
|
||||
assert.equal($('sharpen-toggle').classList.contains('active'), true);
|
||||
assert.equal($('sharpen-slider-row').classList.contains('visible'), true);
|
||||
assert.equal($('sharpen-intensity-slider').value, '100', 'clamped to 100');
|
||||
assert.equal($('sharpen-intensity-value').textContent, '100%');
|
||||
assert.equal(effects.sharpenIntensity, 1);
|
||||
assert.equal(owner.sharpenIntensityPct, 100);
|
||||
assert.ok(shares.length > 0, 'changes reach the share state');
|
||||
|
||||
owner._setBloomEnabled(false);
|
||||
await nextTick();
|
||||
assert.equal($('bloom-toggle').classList.contains('active'), false);
|
||||
assert.equal($('bloom-slider-row').classList.contains('visible'), false);
|
||||
assert.equal($('bloom-intensity-value').textContent, '140%', 'the amount stays');
|
||||
});
|
||||
|
||||
test('a publish never rewrites a slider the user is dragging', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner } = settings(store);
|
||||
owner._setSharpenIntensity(30);
|
||||
await nextTick();
|
||||
const slider = $('sharpen-intensity-slider');
|
||||
let writes = 0;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(
|
||||
Object.getPrototypeOf(slider),
|
||||
'value',
|
||||
);
|
||||
Object.defineProperty(slider, 'value', {
|
||||
get: () => descriptor.get.call(slider),
|
||||
set: (next) => {
|
||||
writes++;
|
||||
descriptor.set.call(slider, next);
|
||||
},
|
||||
});
|
||||
// The input handler applies the dragged value; the publish that follows
|
||||
// carries the same value, so the slider is left alone.
|
||||
owner._setSharpenIntensity(30);
|
||||
owner._setBloomEnabled(true);
|
||||
await nextTick();
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(slider.value, '30');
|
||||
});
|
||||
|
||||
/** VisualSettings with a stand-in detection engine. */
|
||||
function detectionSettings(store) {
|
||||
const { owner } = settings(store);
|
||||
const engine = {
|
||||
mode: 'OFF',
|
||||
tuning: { densityPct: 50, allocationStrategy: 'ELASTIC' },
|
||||
fade: { fadeRatio: 0.07, outsideOpacity: 0.01 },
|
||||
};
|
||||
Object.assign(owner, {
|
||||
_detectionDensityPct: 50,
|
||||
_detectionFadePct: 7,
|
||||
_detectionOutsidePct: 1,
|
||||
_detectionModeLabel: null,
|
||||
_detectionAllocationShown: null,
|
||||
viewer: { scene: { requestRender() {} } },
|
||||
});
|
||||
owner.services = {
|
||||
...owner.services,
|
||||
getDetectionMode: () => engine.mode,
|
||||
setDetectionModeByLabel: (label) => {
|
||||
engine.mode = label;
|
||||
},
|
||||
getDetectionTuning: () => ({ ...engine.tuning }),
|
||||
setDetectionTuning: (next) => Object.assign(engine.tuning, next),
|
||||
getKeyholeFadeTuning: () => ({ ...engine.fade }),
|
||||
setKeyholeFadeTuning: (next) => Object.assign(engine.fade, next),
|
||||
};
|
||||
return { owner, engine };
|
||||
}
|
||||
|
||||
test('detection renders its mode, rows, sliders and allocation', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner, engine } = detectionSettings(store);
|
||||
const button = $('detection-toggle');
|
||||
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Detection overlay');
|
||||
|
||||
owner._setDetectionMode('DENSE');
|
||||
owner._setDetectionDensity(80);
|
||||
await nextTick();
|
||||
assert.equal(button.querySelector('.pp-label').textContent, 'DENSE');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: dense');
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn active panoptic');
|
||||
for (const id of [
|
||||
'detection-slider-row',
|
||||
'detection-allocation-row',
|
||||
'detection-fade-row',
|
||||
'detection-opacity-row',
|
||||
])
|
||||
assert.equal($(id).classList.contains('visible'), true, id);
|
||||
assert.equal($('detection-density-slider').value, '75', 'nearest stop');
|
||||
assert.equal($('detection-density-value').textContent, '75%');
|
||||
assert.equal(engine.tuning.densityPct, 75);
|
||||
|
||||
owner._setDetectionFade({ fadePct: 55 });
|
||||
owner._setDetectionAllocation('weighted', { persist: false });
|
||||
await nextTick();
|
||||
assert.equal($('detection-fade-slider').value, '40', 'clamped to 40');
|
||||
assert.equal($('detection-fade-value').textContent, '40%');
|
||||
assert.equal($('detection-opacity-value').textContent, '1%', 'kept');
|
||||
assert.equal(engine.fade.fadeRatio, 0.4);
|
||||
const weighted = $('detection-allocation-weighted');
|
||||
const elastic = $('detection-allocation-elastic');
|
||||
assert.equal(weighted.classList.contains('active'), true);
|
||||
assert.equal(weighted.getAttribute('aria-checked'), 'true');
|
||||
assert.equal(elastic.classList.contains('active'), false);
|
||||
assert.equal(elastic.getAttribute('aria-checked'), 'false');
|
||||
assert.deepEqual(owner.getDetectionState(), {
|
||||
detectionMode: 'DENSE',
|
||||
densityPct: 75,
|
||||
allocationStrategy: 'WEIGHTED',
|
||||
fadePct: 40,
|
||||
outsideOpacityPct: 1,
|
||||
});
|
||||
|
||||
owner._setDetectionMode('OFF');
|
||||
await nextTick();
|
||||
assert.equal(button.querySelector('.pp-label').textContent, 'DETECT');
|
||||
assert.equal(button.getAttribute('aria-label'), 'Detection overlay: off');
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn');
|
||||
assert.equal($('detection-slider-row').classList.contains('visible'), false);
|
||||
});
|
||||
|
||||
test('the HUD and Sonar controls render what VisualSettings shows', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner } = settings(store);
|
||||
let visible = true;
|
||||
let variant = 'minimal';
|
||||
owner.readHud = () => ({
|
||||
get visible() {
|
||||
return visible;
|
||||
},
|
||||
getVariant: () => variant,
|
||||
});
|
||||
owner._scheduleAdaptivePanelLayout = () => {};
|
||||
assert.equal($('cyber-sonar-toggle').textContent, 'ON');
|
||||
assert.equal($('cyber-sonar-toggle').getAttribute('aria-pressed'), 'true');
|
||||
|
||||
owner._syncHudControls();
|
||||
await nextTick();
|
||||
assert.equal($('hud-toggle').classList.contains('active'), true);
|
||||
assert.equal($('hud-layout-row').classList.contains('visible'), true);
|
||||
assert.equal($('hud-layout-select').value, 'minimal');
|
||||
|
||||
visible = false;
|
||||
owner._updateHudButtonState();
|
||||
await nextTick();
|
||||
assert.equal($('hud-toggle').classList.contains('active'), false);
|
||||
assert.equal($('hud-layout-row').classList.contains('visible'), false);
|
||||
|
||||
owner._sonarShown = {
|
||||
enabled: false,
|
||||
rings: 5,
|
||||
range: 90,
|
||||
intensity: 40,
|
||||
opacity: 60,
|
||||
sector: 30,
|
||||
};
|
||||
owner._publishDisplay();
|
||||
await nextTick();
|
||||
const toggle = $('cyber-sonar-toggle');
|
||||
assert.equal(toggle.textContent, 'OFF');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(toggle.classList.contains('active'), false);
|
||||
assert.equal($('cyber-sonar-rings').value, '5');
|
||||
assert.equal($('cyber-sonar-rings-value').textContent, '5');
|
||||
assert.equal($('cyber-sonar-range-value').textContent, '90%');
|
||||
assert.equal($('cyber-sonar-sector').value, '30');
|
||||
assert.equal($('cyber-sonar-sector-value').textContent, '30°');
|
||||
});
|
||||
|
||||
test('Scope and Celestial render what VisualSettings shows', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const { owner } = settings(store);
|
||||
const mask = { enabled: true, feather: 0.35 };
|
||||
owner.services = {
|
||||
...owner.services,
|
||||
setScopeMaskEnabled: (on) => (mask.enabled = on),
|
||||
setScopeMaskFeather: (value) => (mask.feather = value),
|
||||
};
|
||||
assert.equal($('scope-toggle').checked, true);
|
||||
assert.equal($('scope-feather-value').textContent, '100%');
|
||||
assert.equal($('celestial-toggle').hasAttribute('aria-disabled'), false);
|
||||
|
||||
owner._setScope({ enabled: false }, { apply: false });
|
||||
await nextTick();
|
||||
assert.equal($('scope-toggle').checked, false);
|
||||
assert.equal($('scope-feather-slider').disabled, true);
|
||||
assert.equal(mask.enabled, true, 'only shown');
|
||||
|
||||
owner._setScope({ enabled: true, featherPct: 250 });
|
||||
await nextTick();
|
||||
assert.equal($('scope-toggle').checked, true);
|
||||
assert.equal($('scope-feather-slider').disabled, false);
|
||||
assert.equal($('scope-feather-slider').value, '200', 'clamped');
|
||||
assert.equal($('scope-feather-value').textContent, '200%');
|
||||
assert.deepEqual(mask, { enabled: true, feather: 2 });
|
||||
|
||||
owner._celestialShown = { active: false, supported: false };
|
||||
owner._publishDisplay();
|
||||
await nextTick();
|
||||
const celestial = $('celestial-toggle');
|
||||
assert.equal(celestial.disabled, true);
|
||||
assert.equal(celestial.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(celestial.title, 'Celestial ring — available in Normal style');
|
||||
|
||||
owner._celestialShown = { active: true, supported: true };
|
||||
owner._publishDisplay();
|
||||
await nextTick();
|
||||
assert.equal(celestial.disabled, false);
|
||||
assert.equal(celestial.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(celestial.classList.contains('active'), true);
|
||||
assert.equal(celestial.title, 'Celestial ring — reveal the full globe');
|
||||
});
|
||||
|
||||
test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const params = [];
|
||||
const owner = new AircraftDisplay({
|
||||
elements: { _models3dBtn: $('models3d-toggle') },
|
||||
readDataManager: () => ({
|
||||
setLayerParams: (id, next) => params.push([id, next]),
|
||||
}),
|
||||
layout() {},
|
||||
store,
|
||||
});
|
||||
const button = $('models3d-toggle');
|
||||
const row = $('models3d-mode-row');
|
||||
const all = $('models3d-mode-all');
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn active');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(row.classList.contains('visible'), true);
|
||||
assert.equal($('models3d-mode-proximity').getAttribute('aria-checked'), 'true');
|
||||
|
||||
// The button leaves the row to its own sync.
|
||||
owner._setModels3dEnabled(false);
|
||||
await nextTick();
|
||||
assert.equal(coreClasses(button), 'pp-toggle-btn');
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(row.classList.contains('visible'), true);
|
||||
owner._syncModels3dModeRow();
|
||||
await nextTick();
|
||||
assert.equal(row.classList.contains('visible'), false);
|
||||
|
||||
owner._setModels3dMode('all');
|
||||
await nextTick();
|
||||
assert.equal(all.classList.contains('active'), true);
|
||||
assert.equal(all.getAttribute('aria-checked'), 'true');
|
||||
assert.equal($('models3d-mode-proximity').classList.contains('active'), false);
|
||||
assert.deepEqual(params.at(-1), ['military', { models3dMode: 'all' }]);
|
||||
|
||||
owner._syncModels3dFromLayerState({
|
||||
options: { flights: { models3d: true, models3dMode: 'proximity' } },
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(row.classList.contains('visible'), true);
|
||||
assert.equal(all.getAttribute('aria-checked'), 'false');
|
||||
});
|
||||
|
||||
test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const toggle = $('draw-toggle');
|
||||
const segment = (shape) =>
|
||||
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
|
||||
assert.equal($('draw-hint').textContent, '');
|
||||
|
||||
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
|
||||
await nextTick();
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn active');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), true);
|
||||
assert.equal($('draw-label-row').classList.contains('visible'), true);
|
||||
assert.equal(segment('line').getAttribute('aria-checked'), 'true');
|
||||
assert.equal(segment('line').classList.contains('active'), true);
|
||||
assert.equal(segment('area').classList.contains('active'), false);
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
|
||||
store.publishDraw({ active: false, shape: 'line', hint: '' });
|
||||
await nextTick();
|
||||
assert.equal(coreClasses(toggle), 'pp-toggle-btn');
|
||||
assert.equal($('draw-mode-row').classList.contains('visible'), false);
|
||||
assert.equal($('draw-label-row').classList.contains('visible'), false);
|
||||
});
|
||||
|
||||
test('the imported files render what the file import publishes and keep focus', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const list = $('geo-import-list');
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(list.children.length, 0);
|
||||
assert.equal($('geo-import-hint').textContent, '');
|
||||
|
||||
const viewer = {
|
||||
container: document.createElement('div'),
|
||||
scene: { requestRender() {} },
|
||||
dataSources: { add: async (ds) => ds, remove: () => true },
|
||||
};
|
||||
const tool = initGeoFileImport({
|
||||
viewer,
|
||||
load: async (file) => ({
|
||||
dataSource: { show: true },
|
||||
name: file.name,
|
||||
format: file.name.endsWith('.kml') ? 'kml' : 'geojson',
|
||||
entityCount: 2,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
}),
|
||||
flyToSource() {},
|
||||
publish: (view) => store.publishGeoImport(view),
|
||||
connect: (actions) => {
|
||||
const token = store.connect(actions);
|
||||
return () => store.detach(token);
|
||||
},
|
||||
});
|
||||
t.after(() => tool.destroy());
|
||||
await tool.importFiles([{ name: '<b>a</b>.geojson' }, { name: 'b.kml' }]);
|
||||
await nextTick();
|
||||
assert.equal(list.className, 'geo-import-list visible');
|
||||
assert.equal($('geo-import-hint').textContent, 'Added 2 files.');
|
||||
const rowsNow = () => [...list.querySelectorAll('.geo-import-row')];
|
||||
const [first, second] = rowsNow();
|
||||
assert.equal(first.children.length, 5);
|
||||
assert.equal(
|
||||
[...first.childNodes].some((node) => node.nodeType === 3 && node.data),
|
||||
false,
|
||||
'no text between the parts',
|
||||
);
|
||||
const [swatch, name, toggle] = first.children;
|
||||
assert.equal(swatch.className, 'geo-import-swatch');
|
||||
assert.ok(swatch.style.background);
|
||||
assert.equal(name.textContent, '<b>a</b>.geojson');
|
||||
assert.equal(name.children.length, 0, 'the name is text');
|
||||
assert.match(name.title, /GEOJSON, 2 map objects$/);
|
||||
assert.equal(toggle.className, 'geo-import-action');
|
||||
assert.equal(toggle.getAttribute('type'), 'button');
|
||||
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(toggle.getAttribute('aria-label'), 'Hide <b>a</b>.geojson');
|
||||
assert.equal(toggle.textContent, 'visibility');
|
||||
assert.equal(first.children[3].hasAttribute('aria-pressed'), false);
|
||||
assert.equal(second.children[0].className, 'geo-import-swatch own-styles');
|
||||
assert.equal(second.children[0].getAttribute('style'), null);
|
||||
|
||||
const button = (row, action) =>
|
||||
row.querySelector(`button[data-action="${action}"]`);
|
||||
// The click lands on the icon inside the button, as in a browser.
|
||||
const press = async (el) => {
|
||||
el.focus();
|
||||
el.firstElementChild.click();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
};
|
||||
await press(button(first, 'toggle'));
|
||||
assert.equal(rowsNow()[0] === first, true, 'the row is kept');
|
||||
assert.equal(document.activeElement === button(first, 'toggle'), true);
|
||||
assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(button(first, 'toggle').textContent, 'visibility_off');
|
||||
assert.equal(button(first, 'toggle').title, 'Show <b>a</b>.geojson');
|
||||
await press(button(first, 'toggle'));
|
||||
assert.equal(document.activeElement === button(first, 'toggle'), true);
|
||||
assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(button(first, 'fly').title, 'Fly to <b>a</b>.geojson');
|
||||
assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove <b>a</b>.geojson');
|
||||
|
||||
await press(button(first, 'remove'));
|
||||
assert.equal(rowsNow().length, 1);
|
||||
assert.equal(document.activeElement === button(second, 'remove'), true);
|
||||
await press(button(second, 'remove'));
|
||||
assert.equal(list.className, 'geo-import-list');
|
||||
assert.equal(document.activeElement === $('geo-import-button'), true);
|
||||
});
|
||||
|
||||
test('the parameters panel shows the active style’s rows and hands each change to its owner', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const panel = $('param-slider-panel');
|
||||
assert.equal(panel.classList.contains('active'), false);
|
||||
assert.equal($('param-sliders').children.length, 0);
|
||||
const changes = [];
|
||||
store.connect({
|
||||
setStyleParameter: (name, value) => changes.push([name, value]),
|
||||
});
|
||||
store.publishParameters({
|
||||
style: 'noir',
|
||||
rows: [
|
||||
{
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: '0.01',
|
||||
digits: 2,
|
||||
value: 0.25,
|
||||
text: '0.25',
|
||||
},
|
||||
],
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(panel.classList.contains('active'), true);
|
||||
const row = $('param-sliders').querySelector('.param-slider-row');
|
||||
assert.equal(row.querySelector('.param-label').textContent, 'Amount');
|
||||
assert.equal(row.querySelector('.param-value').textContent, '0.25');
|
||||
const slider = row.querySelector('input.param-slider');
|
||||
assert.equal(slider.getAttribute('aria-label'), 'Amount');
|
||||
assert.equal(slider.step, '0.01');
|
||||
slider.value = '0.5';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
assert.deepEqual(changes, [['amount', 0.5]]);
|
||||
store.publishParameters(null);
|
||||
await nextTick();
|
||||
assert.equal(panel.classList.contains('active'), false);
|
||||
assert.equal($('param-sliders').children.length, 0);
|
||||
});
|
||||
|
||||
test('VisualSettings publishes a style’s parameters and writes a moved slider’s uniform', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const owner = Object.create(VisualSettings.prototype);
|
||||
const renders = [];
|
||||
let shared = 0;
|
||||
Object.assign(owner, {
|
||||
store,
|
||||
_visualEffects: {
|
||||
stages: {
|
||||
noir: {
|
||||
uniforms: { contrastAmt: 1.2, grainAmt: 0.5, vignetteAmt: 0.5 },
|
||||
},
|
||||
},
|
||||
stop() {},
|
||||
},
|
||||
services: { governorRequestRender: (reason) => renders.push(reason) },
|
||||
_parameters: null,
|
||||
readShareLinks: () => null,
|
||||
_scheduleRightPanelLayout() {},
|
||||
_syncShareState: () => (shared += 1),
|
||||
});
|
||||
owner._parametersToken = store.connect({
|
||||
setStyleParameter: (name, value) => owner.setStyleParameter(name, value),
|
||||
});
|
||||
owner._updateSliderPanel('noir');
|
||||
await nextTick();
|
||||
const labels = () =>
|
||||
[...$('param-sliders').querySelectorAll('.param-label')].map(
|
||||
(label) => label.textContent,
|
||||
);
|
||||
assert.deepEqual(labels(), ['Contrast', 'Grain', 'Vignette']);
|
||||
const grain = $('param-sliders').querySelectorAll('input.param-slider')[1];
|
||||
grain.value = '0.8';
|
||||
grain.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.8);
|
||||
assert.equal(
|
||||
$('param-sliders').querySelectorAll('.param-value')[1].textContent,
|
||||
'0.80',
|
||||
);
|
||||
assert.deepEqual(renders, ['style-param-slider']);
|
||||
assert.equal(shared, 1);
|
||||
// A name that isn't one of the shown style's parameters writes nothing.
|
||||
owner.setStyleParameter('pixelation', 4);
|
||||
assert.equal(owner.stages.noir.uniforms.pixelation, undefined);
|
||||
assert.equal(shared, 1);
|
||||
owner._updateSliderPanel('normal');
|
||||
await nextTick();
|
||||
assert.deepEqual(labels(), []);
|
||||
assert.equal($('param-slider-panel').classList.contains('active'), false);
|
||||
// A preset that resets the shown style's uniforms republishes its rows.
|
||||
owner._updateSliderPanel('noir');
|
||||
owner.stages.noir.uniforms.grainAmt = 0.3;
|
||||
owner._refreshParameters();
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
$('param-sliders').querySelectorAll('.param-value')[1].textContent,
|
||||
'0.30',
|
||||
);
|
||||
owner._lifetime = { destroy() {} };
|
||||
owner.stop();
|
||||
await nextTick();
|
||||
assert.equal(store.parameters, null, 'the sliders go with their owner');
|
||||
assert.equal($('param-slider-panel').classList.contains('active'), false);
|
||||
assert.equal(store.act('setStyleParameter', 'grainAmt', 0.2), undefined);
|
||||
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.3, 'a stopped owner no longer answers');
|
||||
// An older owner's stop leaves a newer owner's rows.
|
||||
const newer = { style: 'noir', rows: [] };
|
||||
owner._parameters = { style: 'noir', rows: [] };
|
||||
store.publishParameters(newer);
|
||||
owner.stop();
|
||||
assert.equal(store.parameters, newer);
|
||||
});
|
||||
|
||||
test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id);
|
||||
const ids = ['hud-toggle', 'detection-toggle', 'param-slider-panel', 'models3d-toggle'];
|
||||
const body = $('pp-toggles').querySelector('.pp-panel-body');
|
||||
const order = () =>
|
||||
[...body.children].filter((el) => el.matches('.pp-toggle-group, #param-slider-panel'))
|
||||
.map((el) => el.id || el.querySelector('[id]')?.id);
|
||||
const home = order();
|
||||
const popover = document.body.appendChild(document.createElement('div'));
|
||||
const slots = {};
|
||||
for (const name of ['hud', 'detection', 'parameters', 'models3d']) {
|
||||
slots[name] = popover.appendChild(document.createElement('div'));
|
||||
}
|
||||
store.publishCockpitPortal(slots);
|
||||
await nextTick();
|
||||
assert.equal(slots.hud.contains($('hud-toggle')), true);
|
||||
assert.equal(slots.detection.contains($('detection-toggle')), true);
|
||||
assert.equal(slots.parameters.firstElementChild, $('param-slider-panel'));
|
||||
assert.equal(slots.models3d.contains($('models3d-toggle')), true);
|
||||
// The teleported controls still render from the store.
|
||||
store.publishParameters({ style: 'noir', rows: [] });
|
||||
await nextTick();
|
||||
assert.equal($('param-slider-panel').classList.contains('active'), true);
|
||||
store.publishCockpitPortal(null);
|
||||
await nextTick();
|
||||
for (const id of ids) assert.equal(body.contains(groupOf(id)), true, id);
|
||||
assert.deepEqual(order(), home);
|
||||
// A second trip uses the same slots.
|
||||
store.publishCockpitPortal(slots);
|
||||
await nextTick();
|
||||
assert.equal(slots.hud.contains($('hud-toggle')), true);
|
||||
store.publishCockpitPortal(null);
|
||||
await nextTick();
|
||||
assert.deepEqual(order(), home);
|
||||
});
|
||||
|
||||
test('a group without a published slot stays home; a replaced slot and an unmount leave nothing behind', async (t) => {
|
||||
const { store, $, unmount } = fixture(t);
|
||||
const body = $('pp-toggles').querySelector('.pp-panel-body');
|
||||
const popover = document.body.appendChild(document.createElement('div'));
|
||||
const slot = () => popover.appendChild(document.createElement('div'));
|
||||
const first = { hud: slot(), detection: slot(), parameters: slot() };
|
||||
store.publishCockpitPortal(first);
|
||||
await nextTick();
|
||||
// No models3d slot: 3D stays in the Display panel, not in body.
|
||||
assert.equal(body.contains($('models3d-toggle')), true);
|
||||
assert.equal(first.hud.contains($('hud-toggle')), true);
|
||||
// A new popover replaces the slots while the groups are out.
|
||||
const second = { ...first, hud: slot() };
|
||||
store.publishCockpitPortal(second);
|
||||
await nextTick();
|
||||
assert.equal(second.hud.contains($('hud-toggle')), true);
|
||||
assert.equal(first.hud.childElementCount, 0);
|
||||
// Unmounting while teleported removes the groups from the slots too.
|
||||
unmount();
|
||||
for (const target of Object.values(second))
|
||||
assert.equal(target.childElementCount, 0);
|
||||
});
|
||||
|
||||
test('the real portal lays out once the groups have moved and puts back the focus the move dropped', async (t) => {
|
||||
const { store, $ } = fixture(t);
|
||||
const popover = document.body.appendChild(document.createElement('div'));
|
||||
for (const name of ['hud', 'detection', 'parameters', 'models3d']) {
|
||||
const slot = popover.appendChild(document.createElement('div'));
|
||||
slot.dataset.cockpitDisplaySlot = name;
|
||||
}
|
||||
const frames = [];
|
||||
const priorFrame = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorFrame;
|
||||
});
|
||||
const layouts = [];
|
||||
const portal = new CockpitDisplayPortal({
|
||||
standardPanel: $('pp-toggles'),
|
||||
cockpitPanel: popover,
|
||||
groups: [
|
||||
['hud', $('hud-toggle').closest('.pp-toggle-group')],
|
||||
['detection', $('detection-toggle').closest('.pp-toggle-group')],
|
||||
['parameters', $('param-slider-panel')],
|
||||
['models3d', $('models3d-toggle').closest('.pp-toggle-group')],
|
||||
],
|
||||
layout: () => layouts.push(popover.contains($('hud-toggle'))),
|
||||
publish: (slots) => store.publishCockpitPortal(slots),
|
||||
afterRender: (callback) => {
|
||||
void nextTick(callback);
|
||||
},
|
||||
});
|
||||
t.after(() => portal.destroy());
|
||||
$('hud-toggle').focus();
|
||||
portal.setActive(true);
|
||||
// Superseded before the flush: one layout, for the last state.
|
||||
portal.setActive(false);
|
||||
portal.setActive(true);
|
||||
await nextTick();
|
||||
assert.deepEqual(layouts, [true]);
|
||||
while (frames.length) frames.shift()();
|
||||
assert.equal(document.activeElement, $('hud-toggle'));
|
||||
// A stopped portal lays out nothing.
|
||||
portal.stop();
|
||||
portal.setActive(false);
|
||||
await nextTick();
|
||||
assert.deepEqual(layouts, [true]);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// src/ui/displayPanelActions.test.mjs — the Display panel's controls, the
|
||||
// clean-view exit and the dock's style buttons call the Display controls'
|
||||
// owner through the display store, and only while it is connected.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp, h, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import { useDisplayStore } from './stores/display.js';
|
||||
import { DisplayBindings } from './displayBindings.js';
|
||||
import CommandDockView from './views/CommandDockView.vue';
|
||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useDisplayStore(pinia);
|
||||
const app = createApp({
|
||||
render: () => [h(DisplayControlsView), h(CommandDockView)],
|
||||
}).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return store;
|
||||
}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
function recorder(store) {
|
||||
const calls = [];
|
||||
const actions = new Proxy(
|
||||
{},
|
||||
{
|
||||
get:
|
||||
(_, name) =>
|
||||
(...args) =>
|
||||
calls.push([name, ...args]),
|
||||
},
|
||||
);
|
||||
return { calls, token: store.connect(actions) };
|
||||
}
|
||||
function input(el, value, type = 'input') {
|
||||
el.value = String(value);
|
||||
el.dispatchEvent(new Event(type, { bubbles: true }));
|
||||
}
|
||||
|
||||
test('buttons, sliders, the layout select and the Scope checkbox call their actions', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
for (const id of [
|
||||
'hud-toggle',
|
||||
'cyber-sonar-toggle',
|
||||
'detection-toggle',
|
||||
'models3d-toggle',
|
||||
'celestial-toggle',
|
||||
'clean-view-toggle',
|
||||
'bloom-toggle',
|
||||
'sharpen-toggle',
|
||||
'clean-view-exit',
|
||||
])
|
||||
$(id).click();
|
||||
input($('hud-layout-select'), 'minimal', 'change');
|
||||
input($('cyber-sonar-rings'), 5);
|
||||
input($('cyber-sonar-range'), 90);
|
||||
input($('cyber-sonar-intensity'), 40);
|
||||
input($('cyber-sonar-opacity'), 60);
|
||||
input($('cyber-sonar-sector'), 30);
|
||||
input($('detection-density-slider'), 75);
|
||||
input($('detection-fade-slider'), 12);
|
||||
input($('detection-opacity-slider'), 3);
|
||||
input($('scope-feather-slider'), 150);
|
||||
input($('bloom-intensity-slider'), 24);
|
||||
input($('sharpen-intensity-slider'), 37);
|
||||
$('scope-toggle').checked = false;
|
||||
$('scope-toggle').dispatchEvent(new Event('change', { bubbles: true }));
|
||||
assert.deepEqual(calls, [
|
||||
['toggleHud'],
|
||||
['toggleSonar'],
|
||||
['cycleDetection'],
|
||||
['toggleModels'],
|
||||
['toggleCelestial'],
|
||||
['toggleCleanView'],
|
||||
['toggleBloom'],
|
||||
['toggleSharpen'],
|
||||
['exitCleanView'],
|
||||
['setHudLayout', 'minimal'],
|
||||
['setSonarRings', 5],
|
||||
['setSonarRange', 90],
|
||||
['setSonarIntensity', 40],
|
||||
['setSonarOpacity', 60],
|
||||
['setSonarSector', 30],
|
||||
// Density passes the slider's own value, as it always has.
|
||||
['setDensity', 75],
|
||||
['setFade', 12],
|
||||
['setOutsideOpacity', 3],
|
||||
['setScopeFeather', 150],
|
||||
['setBloomIntensity', 24],
|
||||
['setSharpenIntensity', 37],
|
||||
['setScopeEnabled', false],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Draw controls call the draw tool and show what it publishes', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
$('draw-toggle').click();
|
||||
document.querySelector('[data-shape="pin"]').click();
|
||||
input($('draw-label-input'), 'Camp');
|
||||
input($('draw-color-select'), 'red', 'change');
|
||||
$('draw-clear').click();
|
||||
assert.deepEqual(calls, [
|
||||
['toggleDraw'],
|
||||
['setDrawShape', 'pin'],
|
||||
['setDrawLabel', 'Camp'],
|
||||
['setDrawColor', 'red'],
|
||||
['clearDraw'],
|
||||
]);
|
||||
store.publishDraw({
|
||||
active: true,
|
||||
shape: 'line',
|
||||
color: 'green',
|
||||
label: '',
|
||||
hint: 'Click to add points.',
|
||||
});
|
||||
await nextTick();
|
||||
const line = document.querySelector('[data-shape="line"]');
|
||||
assert.equal(line.getAttribute('aria-checked'), 'true');
|
||||
assert.ok(line.classList.contains('active'));
|
||||
assert.equal($('draw-color-select').value, 'green');
|
||||
assert.equal($('draw-label-input').value, '', 'a finished shape empties it');
|
||||
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||
assert.equal($('draw-toggle').getAttribute('aria-pressed'), 'true');
|
||||
});
|
||||
|
||||
test('style, allocation and model choices keep their ids and data attributes', async (t) => {
|
||||
const store = mount(t);
|
||||
const { calls } = recorder(store);
|
||||
document.querySelector('.style-btn[data-style="thermal"]').click();
|
||||
$('detection-allocation-weighted').click();
|
||||
$('models3d-mode-all').click();
|
||||
$('models3d-mode-proximity').click();
|
||||
assert.deepEqual(calls, [
|
||||
['setStyle', 'thermal'],
|
||||
['setAllocation', 'WEIGHTED'],
|
||||
['setModelsMode', 'all'],
|
||||
['setModelsMode', 'proximity'],
|
||||
]);
|
||||
assert.equal(
|
||||
$('detection-allocation-weighted').dataset.allocation,
|
||||
'WEIGHTED',
|
||||
);
|
||||
assert.equal($('models3d-mode-all').dataset.mode, 'all');
|
||||
// Their selection follows what the owners present.
|
||||
assert.equal(
|
||||
$('models3d-mode-proximity').getAttribute('aria-checked'),
|
||||
'true',
|
||||
);
|
||||
store.publishModels3d({ pressed: true, mode: 'all', rowVisible: true });
|
||||
await nextTick();
|
||||
assert.equal($('models3d-mode-all').getAttribute('aria-checked'), 'true');
|
||||
assert.ok($('models3d-mode-all').classList.contains('active'));
|
||||
});
|
||||
|
||||
test('a detached or replaced owner hears nothing', (t) => {
|
||||
const store = mount(t);
|
||||
const first = recorder(store);
|
||||
$('bloom-toggle').click();
|
||||
store.detach(first.token);
|
||||
$('bloom-toggle').click();
|
||||
const second = recorder(store);
|
||||
store.detach(first.token);
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(first.calls, [['toggleBloom']]);
|
||||
assert.deepEqual(second.calls, [['toggleBloom']]);
|
||||
});
|
||||
|
||||
test('DisplayBindings answers the controls from init until destroy', (t) => {
|
||||
const store = mount(t);
|
||||
const calls = [];
|
||||
const bindings = new DisplayBindings({
|
||||
viewer: null,
|
||||
services: { isScopeMaskEnabled: () => false, cycleDetectionMode: () => {} },
|
||||
elements: { _locationSearch: document.createElement('input') },
|
||||
operations: {
|
||||
_setScope: () => {},
|
||||
_setBloomEnabled: (enabled) => calls.push(['bloom', enabled]),
|
||||
},
|
||||
readState: () => ({ bloomEnabled: false }),
|
||||
claimDetection: () => {},
|
||||
displayStore: store,
|
||||
});
|
||||
bindings._initUI();
|
||||
// Initialising again replaces its own connection rather than stacking one.
|
||||
bindings._initUI();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
// A newer owner takes over; the old one's destroy leaves it connected.
|
||||
const next = recorder(store);
|
||||
bindings.destroy();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
assert.deepEqual(next.calls, [['toggleBloom']]);
|
||||
store.detach(next.token);
|
||||
bindings._initUI();
|
||||
bindings.destroy();
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [['bloom', true]]);
|
||||
});
|
||||
|
||||
test('several owners answer their own actions, the newest first', (t) => {
|
||||
const store = mount(t);
|
||||
const calls = [];
|
||||
const owner = (tag, names) =>
|
||||
store.connect(
|
||||
Object.fromEntries(
|
||||
names.map((name) => [name, () => calls.push([tag, name])]),
|
||||
),
|
||||
);
|
||||
const display = owner('display', ['toggleBloom', 'setStyle']);
|
||||
const draw = owner('draw', ['toggleDraw']);
|
||||
const newer = owner('newer', ['toggleBloom']);
|
||||
$('bloom-toggle').click();
|
||||
$('draw-toggle').click();
|
||||
store.detach(newer);
|
||||
$('bloom-toggle').click();
|
||||
store.detach(draw);
|
||||
$('draw-toggle').click();
|
||||
store.detach(display);
|
||||
$('bloom-toggle').click();
|
||||
assert.deepEqual(calls, [
|
||||
['newer', 'toggleBloom'],
|
||||
['draw', 'toggleDraw'],
|
||||
['display', 'toggleBloom'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Cyber keeps the frame fixed; other layouts retain their existing scroller. */
|
||||
export function displayPanelScroller(panel) {
|
||||
return panel?.ownerDocument?.documentElement?.dataset.uiTheme === 'cyber'
|
||||
? panel.querySelector('.pp-panel-body') || panel
|
||||
: panel;
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
export { VisualEffects } from './visualEffects.js';
|
||||
export {
|
||||
STYLES,
|
||||
GLOBAL_POST_DEFAULTS,
|
||||
STYLE_PRESET_DEFAULTS,
|
||||
MILITARY_DETECTION_PRESET,
|
||||
} from './visualPresets.js';
|
||||
@@ -0,0 +1,69 @@
|
||||
// Flights / Military feed failure texts reach the DATA LAYERS row in the
|
||||
// UI language; the feeds keep them in English.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { LayerPanel } from './layerPanel.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { httpError, LiveSourceError } from '../sources/live/contract.js';
|
||||
|
||||
const panel = LayerPanel.prototype;
|
||||
|
||||
function flightsRow(error) {
|
||||
return panel._buildMetaText({
|
||||
id: 'flights',
|
||||
enabled: true,
|
||||
stats: {
|
||||
count: 0,
|
||||
stale: true,
|
||||
error,
|
||||
status: 429,
|
||||
retryInSec: 45,
|
||||
source: 'OpenSky',
|
||||
lastUpdate: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cases = [
|
||||
['OpenSky 429 anonymous', 'OpenSky rate limited (anonymous)'],
|
||||
['OpenSky auth', 'OpenSky auth required'],
|
||||
['generic 429', httpError({ status: 429 }, 'Flights').message],
|
||||
['HTTP 503', httpError({ status: 503 }, 'Military aircraft').message],
|
||||
[
|
||||
'network error',
|
||||
new LiveSourceError('unavailable', 'Flights network error').message,
|
||||
],
|
||||
['stale snapshot', 'Source snapshot 7 min old'],
|
||||
['snapshot time unknown', 'Source snapshot time unavailable'],
|
||||
['non-LiveSourceError', 'Live data unavailable'],
|
||||
['malformed', 'Malformed OpenSky response'],
|
||||
['malformed rows', 'Malformed adsb.lol aircraft rows'],
|
||||
];
|
||||
|
||||
test('en: feed failure texts stay English', () => {
|
||||
setLocale('en');
|
||||
for (const [, message] of cases)
|
||||
assert.equal(
|
||||
flightsRow(message),
|
||||
`STALE · OpenSky · ${message} · retry 45s`,
|
||||
);
|
||||
});
|
||||
|
||||
for (const locale of ['es', 'ru']) {
|
||||
test(`${locale}: feed failure texts are translated in the layer row`, () => {
|
||||
setLocale(locale);
|
||||
try {
|
||||
for (const [name, message] of cases) {
|
||||
const row = flightsRow(message);
|
||||
assert.ok(
|
||||
!row.includes(message),
|
||||
`${locale} ${name}: row still contains English detail: ${row}`,
|
||||
);
|
||||
}
|
||||
assert.match(flightsRow('Source snapshot 7 min old'), /\b7\b/);
|
||||
assert.match(flightsRow('Flights HTTP 503'), /\b503\b/);
|
||||
} finally {
|
||||
setLocale('en');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/** Frame the complete finite footprint of a session-local file. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { guardCameraAboveGround } from '../cameraGroundGuard.js';
|
||||
import { rangeForBoundingSphere } from '../data/cameraFraming.js';
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
|
||||
/** All visible positions, path vertices, polygon rings, walls and overlay bounds. */
|
||||
export function dataSourcePoints(dataSource, time = Cesium.JulianDate.now()) {
|
||||
const points = [];
|
||||
const add = (position, grounded = false) => {
|
||||
if (
|
||||
!position ||
|
||||
![position.x, position.y, position.z].every(Number.isFinite)
|
||||
)
|
||||
return;
|
||||
const c = Cesium.Cartographic.fromCartesian(position);
|
||||
if (c && Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
|
||||
points.push([
|
||||
Cesium.Math.toDegrees(c.longitude),
|
||||
Cesium.Math.toDegrees(c.latitude),
|
||||
grounded ? 0 : c.height,
|
||||
]);
|
||||
};
|
||||
if (dataSource?.show === false) return points;
|
||||
for (const entity of dataSource?.importedEntities ??
|
||||
dataSource?.entities?.values ??
|
||||
[]) {
|
||||
if (entity.isShowing === false) continue;
|
||||
add(
|
||||
entity.position?.getValue?.(time),
|
||||
entity.billboard?.heightReference?.getValue?.(time) ===
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
for (const graphic of [entity.polyline, entity.wall])
|
||||
for (const position of graphic?.positions?.getValue?.(time) || [])
|
||||
add(position, graphic.clampToGround?.getValue?.(time) === true);
|
||||
const rings = [entity.polygon?.hierarchy?.getValue?.(time)];
|
||||
while (rings.length) {
|
||||
const ring = rings.pop();
|
||||
for (const position of ring?.positions || [])
|
||||
add(
|
||||
position,
|
||||
entity.polygon?.perPositionHeight?.getValue?.(time) !== true,
|
||||
);
|
||||
for (const hole of ring?.holes || []) rings.push(hole);
|
||||
}
|
||||
const rectangle = entity.rectangle?.coordinates?.getValue?.(time);
|
||||
if (rectangle) {
|
||||
const bounds = Cesium.RectangleGeometry.computeRectangle({
|
||||
rectangle,
|
||||
rotation: entity.rectangle.rotation?.getValue?.(time) || 0,
|
||||
});
|
||||
for (const corner of [
|
||||
'southwest',
|
||||
'southeast',
|
||||
'northwest',
|
||||
'northeast',
|
||||
]) {
|
||||
const c = Cesium.Rectangle[corner](bounds);
|
||||
if (Number.isFinite(c.longitude) && Number.isFinite(c.latitude))
|
||||
points.push([
|
||||
Cesium.Math.toDegrees(c.longitude),
|
||||
Cesium.Math.toDegrees(c.latitude),
|
||||
entity.rectangle.height?.getValue?.(time) || 0,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** Fly synchronously to the footprint; later navigation always owns its camera. */
|
||||
export function flyToDataSource(
|
||||
viewer,
|
||||
dataSource,
|
||||
{ duration = 1.5, ground = null } = {},
|
||||
) {
|
||||
const points = dataSourcePoints(dataSource);
|
||||
if (!points.length) return false;
|
||||
cancelCameraArrival(viewer);
|
||||
const positions = points.map(([lon, lat, height]) =>
|
||||
Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||||
);
|
||||
const sphere = Cesium.BoundingSphere.fromPoints(positions);
|
||||
const center =
|
||||
Cesium.Cartographic.fromCartesian(sphere.center) ||
|
||||
Cesium.Cartographic.fromDegrees(points[0][0], points[0][1]);
|
||||
const lat = Cesium.Math.toDegrees(center.latitude),
|
||||
lon = Cesium.Math.toDegrees(center.longitude);
|
||||
const terrain = viewer.scene?.globe?.getHeight?.(center);
|
||||
const cached = ground?.cachedGroundFloor?.(lat, lon);
|
||||
const height =
|
||||
Number.isFinite(terrain) && terrain > 0
|
||||
? terrain
|
||||
: Number.isFinite(cached)
|
||||
? cached
|
||||
: Number.isFinite(terrain)
|
||||
? terrain
|
||||
: 0;
|
||||
ground?.warmGroundFloor?.([{ lat, lon }]);
|
||||
sphere.center = Cesium.Cartesian3.fromRadians(
|
||||
center.longitude,
|
||||
center.latitude,
|
||||
Math.max(0, height, center.height),
|
||||
);
|
||||
sphere.radius = 0;
|
||||
for (const position of positions)
|
||||
sphere.radius = Math.max(
|
||||
sphere.radius,
|
||||
Cesium.Cartesian3.distance(sphere.center, position),
|
||||
);
|
||||
viewer.camera?.flyToBoundingSphere?.(sphere, {
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
viewer.camera.heading || 0,
|
||||
-Math.PI / 2,
|
||||
Math.max(800, rangeForBoundingSphere(viewer, sphere.radius)),
|
||||
),
|
||||
duration,
|
||||
complete: () => guardCameraAboveGround(viewer, { lat, lon }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
import { dataSourcePoints, flyToDataSource } from './flyToDataSource.js';
|
||||
|
||||
function source() {
|
||||
const ds = new Cesium.CustomDataSource('t');
|
||||
ds.entities.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(-122.4862, 37.7694, -2667),
|
||||
billboard: { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND },
|
||||
});
|
||||
ds.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray([
|
||||
-122.49, 37.77, -122.48, 37.771,
|
||||
]),
|
||||
},
|
||||
});
|
||||
ds.entities.add({
|
||||
polygon: {
|
||||
hierarchy: Cesium.Cartesian3.fromDegreesArray([
|
||||
-122.5, 37.76, -122.49, 37.76, -122.49, 37.765,
|
||||
]),
|
||||
},
|
||||
});
|
||||
return ds;
|
||||
}
|
||||
|
||||
test('points come from positions, lines and polygons, as lon/lat only', () => {
|
||||
const points = dataSourcePoints(source());
|
||||
assert.equal(points.length, 6);
|
||||
assert.ok(Math.abs(points[0][0] + 122.4862) < 1e-6);
|
||||
assert.ok(Math.abs(points[0][1] - 37.7694) < 1e-6);
|
||||
});
|
||||
|
||||
test('the camera frames the footprint at the surface, never a buried center', () => {
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
camera: {
|
||||
heading: 0,
|
||||
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
|
||||
},
|
||||
flyTo: () => calls.push('viewer.flyTo'),
|
||||
};
|
||||
assert.equal(flyToDataSource(viewer, source()), true);
|
||||
const center = Cesium.Cartographic.fromCartesian(calls[0].sphere.center);
|
||||
assert.ok(Math.abs(center.height) < 50, `center height ${center.height}`);
|
||||
assert.ok(calls[0].options.offset.range >= 800);
|
||||
assert.equal(
|
||||
typeof calls[0].options.complete,
|
||||
'function',
|
||||
'the ground guard runs on landing',
|
||||
);
|
||||
assert.equal(
|
||||
flyToDataSource(viewer, new Cesium.CustomDataSource('empty')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
1,
|
||||
'empty sources never start deferred navigation',
|
||||
);
|
||||
});
|
||||
|
||||
test('a very long track is framed without spreading it into a call', () => {
|
||||
// 400k vertices: `push(...line)` throws RangeError (too many arguments)
|
||||
// well below this; the walk keeps a bounded sample and both ends.
|
||||
const n = 400_000;
|
||||
const line = new Array(n);
|
||||
for (let i = 0; i < n; i++)
|
||||
line[i] = Cesium.Cartesian3.fromDegrees(-120 + (i / n) * 2, 40);
|
||||
const ds = new Cesium.CustomDataSource('long');
|
||||
ds.entities.add({ polyline: { positions: line } });
|
||||
ds.entities.add({ position: Cesium.Cartesian3.fromDegrees(-110, 41) });
|
||||
const points = dataSourcePoints(ds);
|
||||
assert.equal(points.length, n + 1, 'every vertex contributes to framing');
|
||||
const lons = points.map(([lon]) => lon);
|
||||
assert.ok(
|
||||
lons.reduce((a, b) => Math.min(a, b), Infinity) < -119.99 &&
|
||||
lons.reduce((a, b) => Math.max(a, b), -Infinity) > -110.01,
|
||||
);
|
||||
assert.ok(lons.some((lon) => Math.abs(lon - -118.000005) < 0.01));
|
||||
});
|
||||
|
||||
test('framing includes a sparse interior outlier, walls and rotated overlays', () => {
|
||||
const ds = new Cesium.CustomDataSource('bounds');
|
||||
const line = Array.from({ length: 200031 }, () =>
|
||||
Cesium.Cartesian3.fromDegrees(0, 0),
|
||||
);
|
||||
line[12345] = Cesium.Cartesian3.fromDegrees(20, 10);
|
||||
ds.entities.add({ polyline: { positions: line } });
|
||||
ds.entities.add({
|
||||
wall: { positions: Cesium.Cartesian3.fromDegreesArray([30, 10, 31, 11]) },
|
||||
});
|
||||
ds.entities.add({
|
||||
rectangle: {
|
||||
coordinates: Cesium.Rectangle.fromDegrees(40, 0, 41, 1),
|
||||
rotation: Math.PI / 4,
|
||||
},
|
||||
});
|
||||
const points = dataSourcePoints(ds);
|
||||
assert.ok(points.some(([lon]) => Math.abs(lon - 20) < 1e-6));
|
||||
assert.ok(points.some(([lon]) => Math.abs(lon - 31) < 1e-6));
|
||||
assert.ok(points.some(([lon]) => lon > 41));
|
||||
});
|
||||
|
||||
test('portrait framing accounts for FOV, high ground and symmetric extents', () => {
|
||||
const ds = new Cesium.CustomDataSource('view');
|
||||
ds.entities.add({
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray([-105, 39, -104.9, 39]),
|
||||
},
|
||||
});
|
||||
const calls = [];
|
||||
const viewer = {
|
||||
scene: { globe: { getHeight: () => 2600 } },
|
||||
camera: {
|
||||
frustum: { fov: Math.PI / 3, aspectRatio: 390 / 844 },
|
||||
flyToBoundingSphere: (sphere, options) => calls.push({ sphere, options }),
|
||||
},
|
||||
};
|
||||
flyToDataSource(viewer, ds);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(calls[0].sphere.center).height - 2600,
|
||||
) < 1e-6,
|
||||
);
|
||||
assert.equal(calls[0].options.offset.pitch, -Math.PI / 2);
|
||||
viewer.camera.frustum.aspectRatio = 16 / 9;
|
||||
flyToDataSource(viewer, ds);
|
||||
assert.ok(calls[0].options.offset.range > calls[1].options.offset.range);
|
||||
const symmetric = {
|
||||
entities: {
|
||||
values: [
|
||||
{ position: { getValue: () => new Cesium.Cartesian3(6378137, 0, 0) } },
|
||||
{ position: { getValue: () => new Cesium.Cartesian3(-6378137, 0, 0) } },
|
||||
],
|
||||
},
|
||||
};
|
||||
assert.doesNotThrow(() => flyToDataSource(viewer, symmetric));
|
||||
assert.ok(Number.isFinite(calls.at(-1).options.offset.range));
|
||||
assert.doesNotThrow(() => calls.at(-1).options.complete());
|
||||
cancelCameraArrival(viewer);
|
||||
});
|
||||
|
||||
test('non-finite positions and hidden features never enter the camera footprint', () => {
|
||||
const position = (p) => ({ position: { getValue: () => p } });
|
||||
const points = dataSourcePoints({
|
||||
entities: {
|
||||
values: [
|
||||
position(new Cesium.Cartesian3(NaN, 1, 2)),
|
||||
{
|
||||
...position(Cesium.Cartesian3.fromDegrees(10, 10)),
|
||||
isShowing: false,
|
||||
},
|
||||
position(Cesium.Cartesian3.fromDegrees(20, 20)),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(points.length, 1);
|
||||
assert.ok(Math.abs(points[0][0] - 20) < 1e-6);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useStatusStore } from './stores/status.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
/**
|
||||
* Count rendered globe frames while the optional readout is visible. The
|
||||
* readout is presented to the status store, which the title bar renders.
|
||||
*/
|
||||
export function createFrameRateMonitor({
|
||||
viewer,
|
||||
documentRef = document,
|
||||
store = useStatusStore(applicationPinia()),
|
||||
}) {
|
||||
const frameEvent = viewer?.scene?.postRender;
|
||||
// A page without the scene chrome has no title bar to show it in.
|
||||
if (!documentRef.getElementById('title-bar') || !frameEvent)
|
||||
return { destroy() {} };
|
||||
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
let readout = { visible: false, text: 'FPS —' };
|
||||
// Each monitor shows its readout until a newer one takes the title bar.
|
||||
store.publishFps(readout);
|
||||
const present = (patch) => {
|
||||
const shown = store.fps === readout;
|
||||
readout = { ...readout, ...patch };
|
||||
if (shown) store.publishFps(readout);
|
||||
};
|
||||
let removeFrameListener = null;
|
||||
let timer = null;
|
||||
let frames = 0;
|
||||
let startedAt = 0;
|
||||
let destroyed = false;
|
||||
|
||||
function hide() {
|
||||
present({ visible: false });
|
||||
removeFrameListener?.();
|
||||
removeFrameListener = null;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
function show() {
|
||||
frames = 0;
|
||||
startedAt = performance.now();
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
present({ visible: true, text: 'FPS —' });
|
||||
removeFrameListener = frameEvent.addEventListener(() => {
|
||||
frames++;
|
||||
});
|
||||
timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
const elapsed = now - startedAt;
|
||||
present({
|
||||
text:
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
documentRef.hidden || elapsed <= 0
|
||||
? 'FPS —'
|
||||
: `FPS ${Math.round((frames * 1000) / elapsed)}`,
|
||||
});
|
||||
frames = 0;
|
||||
startedAt = now;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (event.key !== '`' && event.code !== 'Backquote') return;
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.repeat ||
|
||||
event.isComposing ||
|
||||
event.ctrlKey ||
|
||||
event.altKey ||
|
||||
event.metaKey ||
|
||||
event.shiftKey
|
||||
)
|
||||
return;
|
||||
if (
|
||||
event.target?.isContentEditable ||
|
||||
event.target?.closest?.(
|
||||
'input, textarea, select, [contenteditable]:not([contenteditable="false"])',
|
||||
)
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
if (readout.visible) hide();
|
||||
else show();
|
||||
}
|
||||
|
||||
documentRef.addEventListener('keydown', onKeyDown);
|
||||
return {
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
hide();
|
||||
documentRef.removeEventListener('keydown', onKeyDown);
|
||||
// A newer monitor's readout stays.
|
||||
if (store.fps === readout) store.publishFps(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
|
||||
function fixture(t) {
|
||||
const keys = new Set();
|
||||
const frames = new Set();
|
||||
const store = {
|
||||
fps: undefined,
|
||||
publishFps(next) {
|
||||
this.fps = next;
|
||||
},
|
||||
};
|
||||
const documentRef = {
|
||||
hidden: false,
|
||||
getElementById: (id) => (id === 'title-bar' ? {} : null),
|
||||
addEventListener: (name, handler) => keys.add(handler),
|
||||
removeEventListener: (name, handler) => keys.delete(handler),
|
||||
};
|
||||
const viewer = {
|
||||
scene: {
|
||||
postRender: {
|
||||
addEventListener(handler) {
|
||||
frames.add(handler);
|
||||
return () => frames.delete(handler);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const monitor = createFrameRateMonitor({ viewer, documentRef, store });
|
||||
t.after(() => monitor.destroy());
|
||||
return {
|
||||
monitor,
|
||||
documentRef,
|
||||
frames,
|
||||
keys,
|
||||
readout: () => store.fps,
|
||||
press(options = {}) {
|
||||
const event = {
|
||||
key: '`',
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
...options,
|
||||
};
|
||||
for (const handler of keys) handler(event);
|
||||
return event;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('FPS measures rendered frames only while shown and releases its resources', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||
let now = 0;
|
||||
t.mock.method(performance, 'now', () => now);
|
||||
const f = fixture(t);
|
||||
assert.equal(f.readout().visible, false);
|
||||
assert.equal(f.frames.size, 0);
|
||||
assert.equal(f.press().defaultPrevented, true);
|
||||
assert.equal(f.frames.size, 1);
|
||||
for (let frame = 0; frame < 30; frame++)
|
||||
for (const render of f.frames) render();
|
||||
now = 1000;
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(f.readout().text, 'FPS 30');
|
||||
now = 2000;
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(
|
||||
f.readout().text,
|
||||
'FPS 0',
|
||||
'idle rendering is not reported as display refresh rate',
|
||||
);
|
||||
f.press();
|
||||
assert.equal(f.readout().visible, false);
|
||||
assert.equal(f.frames.size, 0);
|
||||
now = 3000;
|
||||
t.mock.timers.tick(1000);
|
||||
f.press();
|
||||
assert.equal(f.readout().text, 'FPS —');
|
||||
f.monitor.destroy();
|
||||
f.monitor.destroy();
|
||||
assert.equal(f.frames.size, 0);
|
||||
assert.equal(f.keys.size, 0);
|
||||
assert.equal(f.readout(), null, 'destroy withdraws the readout');
|
||||
});
|
||||
|
||||
test('typing, modified keys, composition and repeated presses do not toggle FPS', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const options of [
|
||||
{ target: { closest: () => ({}) } },
|
||||
{ target: { isContentEditable: true } },
|
||||
{ ctrlKey: true },
|
||||
{ altKey: true },
|
||||
{ metaKey: true },
|
||||
{ shiftKey: true },
|
||||
{ repeat: true },
|
||||
{ isComposing: true },
|
||||
{ defaultPrevented: true },
|
||||
{ key: 'a' },
|
||||
]) {
|
||||
f.press(options);
|
||||
assert.equal(f.readout().visible, false);
|
||||
assert.equal(f.frames.size, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('a page without the title bar gets no readout and keeps the key', () => {
|
||||
let listened = false;
|
||||
const store = { publishFps: () => assert.fail('nothing to show it in') };
|
||||
const monitor = createFrameRateMonitor({
|
||||
viewer: { scene: { postRender: { addEventListener: () => () => {} } } },
|
||||
documentRef: {
|
||||
getElementById: () => null,
|
||||
addEventListener: () => (listened = true),
|
||||
},
|
||||
store,
|
||||
});
|
||||
monitor.destroy();
|
||||
assert.equal(listened, false);
|
||||
});
|
||||
|
||||
test("destroying an earlier monitor leaves a newer one's readout", () => {
|
||||
const store = {
|
||||
publishFps(next) {
|
||||
this.fps = next;
|
||||
},
|
||||
};
|
||||
const documentRef = {
|
||||
getElementById: () => ({}),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const viewer = {
|
||||
scene: { postRender: { addEventListener: () => () => {} } },
|
||||
};
|
||||
const first = createFrameRateMonitor({ viewer, documentRef, store });
|
||||
const second = createFrameRateMonitor({ viewer, documentRef, store });
|
||||
const shown = store.fps;
|
||||
first.destroy();
|
||||
assert.ok(store.fps === shown);
|
||||
second.destroy();
|
||||
assert.equal(store.fps, null);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// src/ui/frameRateView.test.mjs — the title bar's FPS readout renders what
|
||||
// createFrameRateMonitor() publishes and toggles with the backquote key.
|
||||
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 { useStatusStore } from './stores/status.js';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import SceneChromeView from './views/SceneChromeView.vue';
|
||||
|
||||
test('the FPS readout sits in the title bar, hidden until the backquote key', 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 readout = () =>
|
||||
document.querySelector('#title-bar .frame-rate-readout');
|
||||
assert.equal(readout(), null, 'no readout without a monitor');
|
||||
const monitor = createFrameRateMonitor({
|
||||
viewer: { scene: { postRender: { addEventListener: () => () => {} } } },
|
||||
store: useStatusStore(pinia),
|
||||
});
|
||||
t.after(() => monitor.destroy());
|
||||
await nextTick();
|
||||
const element = readout();
|
||||
assert.ok(element.previousElementSibling.classList.contains('subtitle'));
|
||||
assert.ok(element.classList.contains('scene-text-holdout'));
|
||||
assert.equal(element.hidden, true);
|
||||
assert.equal(element.textContent, 'FPS —');
|
||||
assert.equal(
|
||||
element.title,
|
||||
'Rendered globe frames per second · toggle with `',
|
||||
);
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: '`', bubbles: true, cancelable: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(readout().hidden, false);
|
||||
monitor.destroy();
|
||||
await nextTick();
|
||||
assert.equal(readout(), null);
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/** Session-local file controls, serial loading and application-owned navigation. */
|
||||
import {
|
||||
GeoImportError,
|
||||
MAX_IMPORTED_FILES,
|
||||
loadGeoFile,
|
||||
} from '../data/geoFileImport.js';
|
||||
import { formatNumber, t, tn, translateKnown } from '../i18n/index.js';
|
||||
import { flyToDataSource } from './flyToDataSource.js';
|
||||
|
||||
// One color per file, in import order, from the whiteboard palette.
|
||||
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
|
||||
|
||||
/** An import error's values, with its counts and limits in local numerals. */
|
||||
function localizedValues(values) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values || {}).map(([name, value]) => [
|
||||
name,
|
||||
typeof value === 'number' ? formatNumber(value) : value,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the Import control.
|
||||
* @param {{viewer: object, load?: Function, document?: Document,
|
||||
* publish?: Function, connect?: Function}} deps
|
||||
* `load(file, {viewer, color})` defaults to `loadGeoFile`; tests pass a double.
|
||||
* `publish({rows, hint})` hands the file list and hint to the view that
|
||||
* renders them. `connect(actions)` answers the view's pick and row buttons
|
||||
* and returns their disconnect.
|
||||
* @returns {{destroy: Function, importFiles: Function, list: Function,
|
||||
* setVisible: Function, flyTo: Function, remove: Function,
|
||||
* diagnostics: Function}|null}
|
||||
*/
|
||||
export function initGeoFileImport({
|
||||
viewer,
|
||||
load = loadGeoFile,
|
||||
document: doc = globalThis.document,
|
||||
flyToSource = flyToDataSource,
|
||||
navigation = null,
|
||||
ground = null,
|
||||
subscribeMapSource = null,
|
||||
publish = () => {},
|
||||
connect = () => () => {},
|
||||
}) {
|
||||
// A page without the Import control (the Display controls view omitted)
|
||||
// has no file import.
|
||||
if (!viewer || !doc?.getElementById('geo-import-button')) return null;
|
||||
|
||||
const imports = [];
|
||||
const listeners = [];
|
||||
let queue = Promise.resolve();
|
||||
let seq = 0;
|
||||
let colorAt = 0;
|
||||
let destroyed = false;
|
||||
let dragDepth = 0;
|
||||
let cameraIntent = 0;
|
||||
let hint = '';
|
||||
const lifetime = new AbortController();
|
||||
let removeMapSource;
|
||||
|
||||
const listen = (target, type, listener, options) => {
|
||||
if (!target?.addEventListener) return;
|
||||
target.addEventListener(type, listener, options);
|
||||
listeners.push([target, type, listener, options]);
|
||||
};
|
||||
const setHint = (text) => {
|
||||
hint = text || '';
|
||||
present();
|
||||
};
|
||||
const render = () => viewer.scene?.requestRender?.();
|
||||
let groundTimer,
|
||||
groundQueued = false,
|
||||
groundAgain = false;
|
||||
const groundRemovers = [];
|
||||
const scheduleGround = () => {
|
||||
if (destroyed || !imports.length) return;
|
||||
if (groundQueued) {
|
||||
groundAgain = true;
|
||||
return;
|
||||
}
|
||||
clearTimeout(groundTimer);
|
||||
groundTimer = setTimeout(() => {
|
||||
groundQueued = true;
|
||||
queue = queue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
try {
|
||||
for (const item of imports) {
|
||||
if (destroyed) return;
|
||||
if (item.visible) await item.refreshGround?.(lifetime.signal);
|
||||
}
|
||||
render();
|
||||
} catch {
|
||||
if (!destroyed)
|
||||
setHint(
|
||||
t(
|
||||
'Ground heights could not be refreshed. Try the file again when the map has loaded.',
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
groundQueued = false;
|
||||
if (groundAgain) {
|
||||
groundAgain = false;
|
||||
scheduleGround();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
removeMapSource = subscribeMapSource?.(scheduleGround);
|
||||
const ownEvent = (event, listener) => {
|
||||
const remove = event?.addEventListener?.(listener);
|
||||
if (remove) groundRemovers.push(remove);
|
||||
};
|
||||
ownEvent(viewer.camera?.moveEnd, scheduleGround);
|
||||
ownEvent(viewer.scene?.globe?.tileLoadProgressEvent, (pending) => {
|
||||
if (!pending) scheduleGround();
|
||||
});
|
||||
|
||||
/** Publish the file list and hint; the view renders them. */
|
||||
function present() {
|
||||
return publish({
|
||||
rows: imports.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
// KML and KMZ draw with their own styles; their swatch says so
|
||||
// instead of claiming a color the globe does not show.
|
||||
ownStyles: item.ownStyles,
|
||||
color: item.color,
|
||||
title: `${item.name} — ${item.format.toUpperCase()}, ${tn(
|
||||
item.entityCount,
|
||||
'{count} map object',
|
||||
'{count} map objects',
|
||||
{ count: formatNumber(item.entityCount) },
|
||||
)}${item.ownStyles ? t(', drawn with its own styles') : ''}`,
|
||||
actions: [
|
||||
{
|
||||
icon: item.visible ? 'visibility' : 'visibility_off',
|
||||
label: item.visible
|
||||
? t('Hide {name}', { name: item.name })
|
||||
: t('Show {name}', { name: item.name }),
|
||||
action: 'toggle',
|
||||
pressed: String(item.visible),
|
||||
},
|
||||
{
|
||||
icon: 'my_location',
|
||||
label: t('Fly to {name}', { name: item.name }),
|
||||
action: 'fly',
|
||||
},
|
||||
{
|
||||
icon: 'close',
|
||||
label: t('Remove {name}', { name: item.name }),
|
||||
action: 'remove',
|
||||
},
|
||||
],
|
||||
})),
|
||||
hint,
|
||||
});
|
||||
}
|
||||
|
||||
const find = (id) => imports.find((item) => item.id === id) || null;
|
||||
|
||||
/** Show or hide one imported file. */
|
||||
function setVisible(id, visible) {
|
||||
const item = find(id);
|
||||
if (!item) return false;
|
||||
item.visible = Boolean(visible);
|
||||
item.dataSource.show = item.visible;
|
||||
if (item.visible) scheduleGround();
|
||||
present();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not viewer.flyTo: it frames ground-clamped points underground until the
|
||||
// destination's terrain has loaded (see ./flyToDataSource.js).
|
||||
const fly = (item) => {
|
||||
if (!item.visible) setVisible(item.id, true);
|
||||
flyToSource(viewer, item.dataSource, { ground });
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fly the camera to one imported file: an immediate navigation, so Cockpit
|
||||
* can refuse it and the current follow/track owner is released first.
|
||||
*/
|
||||
function flyTo(id) {
|
||||
const item = find(id);
|
||||
if (!item) return false;
|
||||
if (typeof navigation?.runImmediateNavigation === 'function')
|
||||
return (
|
||||
navigation.runImmediateNavigation('file', () => fly(item)) !== false
|
||||
);
|
||||
return fly(item);
|
||||
}
|
||||
|
||||
/** Stamp a deferred fly for an import batch; false when refused. */
|
||||
const beginAutoFly = () =>
|
||||
typeof navigation?.beginDeferredLocationNavigation === 'function'
|
||||
? navigation.beginDeferredLocationNavigation()
|
||||
: null;
|
||||
/** Re-assert the stamp right before flying; false when superseded. */
|
||||
const autoFlyStillOwns = (generation) => {
|
||||
if (generation === false) return false;
|
||||
if (generation === null) return true;
|
||||
return typeof navigation?.reassertDeferredLocationNavigation === 'function'
|
||||
? navigation.reassertDeferredLocationNavigation(generation) === true
|
||||
: true;
|
||||
};
|
||||
|
||||
/** Remove one imported file from the globe and the list. */
|
||||
function remove(id) {
|
||||
const at = imports.findIndex((item) => item.id === id);
|
||||
if (at < 0) return false;
|
||||
const [item] = imports.splice(at, 1);
|
||||
viewer.dataSources?.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
present();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import files, one after another. Returns one outcome per file.
|
||||
* @param {Iterable<File>} files
|
||||
* @returns {Promise<Array<{name: string, ok: boolean, id?: string,
|
||||
* removed?: number, error?: string}>>}
|
||||
*/
|
||||
function importFiles(files) {
|
||||
const list = [...(files || [])];
|
||||
if (destroyed || !list.length) return Promise.resolve([]);
|
||||
// Capture the user's intent when the files are chosen, including time
|
||||
// spent waiting for an earlier batch. Later navigation must still win.
|
||||
const generation = beginAutoFly();
|
||||
const intent = ++cameraIntent;
|
||||
const run = queue.then(() => importBatch(list, generation, intent));
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
async function importBatch(list, generation, intent) {
|
||||
const outcomes = [];
|
||||
if (destroyed || !list.length) return outcomes;
|
||||
for (const file of list) {
|
||||
if (destroyed) break;
|
||||
const fileName = String(file?.name || 'file');
|
||||
if (imports.length >= MAX_IMPORTED_FILES) {
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: false,
|
||||
error: t('At most {count} files at once — remove one first.', {
|
||||
count: formatNumber(MAX_IMPORTED_FILES),
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
setHint(t('Reading {name}…', { name: fileName }));
|
||||
const color = COLORS[colorAt % COLORS.length];
|
||||
let loaded;
|
||||
try {
|
||||
loaded = await load(file, { viewer, color, signal: lifetime.signal });
|
||||
} catch (error) {
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: false,
|
||||
error:
|
||||
error instanceof GeoImportError || error?.name === 'GeoImportError'
|
||||
? t(
|
||||
error.template || error.message,
|
||||
localizedValues(error.values),
|
||||
)
|
||||
: t('That file could not be read.'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (destroyed) {
|
||||
loaded.revoke?.();
|
||||
break;
|
||||
}
|
||||
colorAt += 1;
|
||||
seq += 1;
|
||||
const item = {
|
||||
id: `import-${seq}`,
|
||||
name: loaded.name || fileName,
|
||||
format: loaded.format,
|
||||
ownStyles: loaded.format === 'kml' || loaded.format === 'kmz',
|
||||
entityCount: loaded.entityCount,
|
||||
removed: loaded.removed || 0,
|
||||
color,
|
||||
visible: true,
|
||||
dataSource: loaded.dataSource,
|
||||
revoke: loaded.revoke,
|
||||
refreshGround: loaded.refreshGround,
|
||||
};
|
||||
await viewer.dataSources.add(item.dataSource);
|
||||
if (destroyed) {
|
||||
viewer.dataSources.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
break;
|
||||
}
|
||||
loaded.mount?.();
|
||||
imports.push(item);
|
||||
present();
|
||||
render();
|
||||
outcomes.push({
|
||||
name: fileName,
|
||||
ok: true,
|
||||
id: item.id,
|
||||
removed: item.removed,
|
||||
});
|
||||
// Fly to the first file of a batch that lands, so a drop shows its
|
||||
// result, unless something else took the camera meanwhile.
|
||||
if (
|
||||
outcomes.filter((o) => o.ok).length === 1 &&
|
||||
intent === cameraIntent &&
|
||||
autoFlyStillOwns(generation)
|
||||
)
|
||||
fly(item);
|
||||
}
|
||||
if (!destroyed) setHint(summarize(outcomes));
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
// ---- controls ----------------------------------------------------------
|
||||
// The view opens the picker; its pick and the row buttons call these.
|
||||
const disconnect = connect({
|
||||
importGeoFiles: (files) => void importFiles(files),
|
||||
toggleGeoImport: (id) => setVisible(id, !find(id)?.visible),
|
||||
flyToGeoImport: flyTo,
|
||||
removeGeoImport: remove,
|
||||
});
|
||||
|
||||
// ---- drop onto the globe ----------------------------------------------
|
||||
const container = viewer.container;
|
||||
const gesture = () => {
|
||||
cameraIntent += 1;
|
||||
};
|
||||
listen(viewer.scene?.canvas || container, 'pointerdown', gesture, {
|
||||
passive: true,
|
||||
});
|
||||
listen(viewer.scene?.canvas || container, 'wheel', gesture, {
|
||||
passive: true,
|
||||
});
|
||||
const hasFiles = (event) =>
|
||||
[...(event.dataTransfer?.types || [])].includes('Files');
|
||||
const setDragging = (on) =>
|
||||
doc.body?.classList?.toggle('gev-geo-import-drag', on);
|
||||
listen(container, 'dragenter', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth += 1;
|
||||
setDragging(true);
|
||||
});
|
||||
listen(doc, 'dragover', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
listen(container, 'dragleave', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
dragDepth = Math.max(0, dragDepth - 1);
|
||||
if (!dragDepth) setDragging(false);
|
||||
});
|
||||
listen(doc, 'drop', (event) => {
|
||||
if (!hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
setDragging(false);
|
||||
const files = [...(event.dataTransfer?.files || [])];
|
||||
if (files.length) void importFiles(files);
|
||||
});
|
||||
|
||||
present();
|
||||
|
||||
const handle = {
|
||||
importFiles,
|
||||
list: () =>
|
||||
imports.map(({ id, name, format, entityCount, removed, visible }) => ({
|
||||
id,
|
||||
name,
|
||||
format,
|
||||
entityCount,
|
||||
removed,
|
||||
visible,
|
||||
})),
|
||||
setVisible,
|
||||
flyTo,
|
||||
remove,
|
||||
diagnostics: () => ({
|
||||
files: imports.length,
|
||||
entities: imports.reduce((n, item) => n + item.entityCount, 0),
|
||||
listeners: listeners.length,
|
||||
destroyed,
|
||||
}),
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
disconnect();
|
||||
lifetime.abort();
|
||||
removeMapSource?.();
|
||||
clearTimeout(groundTimer);
|
||||
for (const remove of groundRemovers) remove();
|
||||
for (const item of imports.splice(0)) {
|
||||
viewer.dataSources?.remove?.(item.dataSource, true);
|
||||
item.revoke?.();
|
||||
}
|
||||
for (const [target, type, listener, options] of listeners.splice(0))
|
||||
target.removeEventListener(type, listener, options);
|
||||
setDragging(false);
|
||||
setHint('');
|
||||
if (globalThis.window?.__gevGeoImport === handle)
|
||||
delete globalThis.window.__gevGeoImport;
|
||||
},
|
||||
};
|
||||
if (globalThis.window) globalThis.window.__gevGeoImport = handle;
|
||||
return handle;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the hint after an import batch.
|
||||
* @param {Array<{name: string, ok: boolean, error?: string, removed?: number}>} outcomes
|
||||
* @returns {string}
|
||||
*/
|
||||
export function summarize(outcomes) {
|
||||
const failed = outcomes.filter((o) => !o.ok);
|
||||
const added = outcomes.length - failed.length;
|
||||
const parts = [];
|
||||
if (added)
|
||||
parts.push(
|
||||
tn(added, 'Added {count} file.', 'Added {count} files.', {
|
||||
count: formatNumber(added),
|
||||
}),
|
||||
);
|
||||
// Import errors stay English in the outcome; they translate here.
|
||||
const error = translateKnown(failed[0]?.error);
|
||||
if (failed.length === 1) parts.push(`${failed[0].name}: ${error}`);
|
||||
else if (failed.length > 1)
|
||||
parts.push(
|
||||
tn(
|
||||
failed.length,
|
||||
'{count} file could not be added ({name}: {error})',
|
||||
'{count} files could not be added ({name}: {error})',
|
||||
{ count: formatNumber(failed.length), name: failed[0].name, error },
|
||||
),
|
||||
);
|
||||
const stripped = outcomes.reduce(
|
||||
(n, o) => n + (o.ok ? o.removed || 0 : 0),
|
||||
0,
|
||||
);
|
||||
if (added && stripped) parts.push(t('Some KML content was left out.'));
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
// Behavioral tests for DISPLAY ▸ Import: the controller is driven against
|
||||
// fake DOM and viewer doubles with an injected loader, and its observable
|
||||
// effects are asserted — data sources added and removed, the rows and hint it
|
||||
// publishes, drop handling, the file limit, and what destroy() gives back.
|
||||
// The Display panel renders the published list (`displayPanel.test.mjs`);
|
||||
// real parsing and rendering run in `scripts/qa-geo-import.mjs`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { initGeoFileImport, summarize } from './geoFileImport.js';
|
||||
import { GeoImportError, MAX_IMPORTED_FILES } from '../data/geoFileImport.js';
|
||||
|
||||
class FakeClassList {
|
||||
constructor() {
|
||||
this.names = new Set();
|
||||
}
|
||||
add(...n) {
|
||||
n.forEach((x) => this.names.add(x));
|
||||
}
|
||||
remove(...n) {
|
||||
n.forEach((x) => this.names.delete(x));
|
||||
}
|
||||
contains(n) {
|
||||
return this.names.has(n);
|
||||
}
|
||||
toggle(n, force) {
|
||||
const on = force === undefined ? !this.names.has(n) : Boolean(force);
|
||||
if (on) this.names.add(n);
|
||||
else this.names.delete(n);
|
||||
return on;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = 'div', id = '') {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.id = id;
|
||||
this.classList = new FakeClassList();
|
||||
this.listeners = [];
|
||||
}
|
||||
addEventListener(type, listener, options) {
|
||||
this.listeners.push({ type, listener, options });
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
const at = this.listeners.findIndex(
|
||||
(l) => l.type === type && l.listener === listener,
|
||||
);
|
||||
if (at >= 0) this.listeners.splice(at, 1);
|
||||
}
|
||||
emit(type, event = {}) {
|
||||
for (const { listener } of this.listeners.filter((l) => l.type === type))
|
||||
listener({
|
||||
target: this,
|
||||
preventDefault() {
|
||||
this.prevented = true;
|
||||
},
|
||||
...event,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
|
||||
const els = {
|
||||
'geo-import-button': new FakeElement('button', 'geo-import-button'),
|
||||
};
|
||||
const document = {
|
||||
body: new FakeElement('body'),
|
||||
getElementById: (id) => els[id] ?? null,
|
||||
};
|
||||
Object.setPrototypeOf(document, new FakeElement('document'));
|
||||
const added = [];
|
||||
const removed = [];
|
||||
const flights = [];
|
||||
let renders = 0;
|
||||
let shown = null;
|
||||
// The view's pick and row buttons, as the display store holds them.
|
||||
let actions = null;
|
||||
const viewer = {
|
||||
container: new FakeElement('div', 'cesiumContainer'),
|
||||
scene: { requestRender: () => (renders += 1) },
|
||||
dataSources: {
|
||||
add: async (ds) => {
|
||||
added.push(ds);
|
||||
return ds;
|
||||
},
|
||||
remove: (ds, destroy) => {
|
||||
removed.push({ ds, destroy });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
flyTo: (target) => flights.push(target),
|
||||
};
|
||||
const revoked = [];
|
||||
const loads = [];
|
||||
const defaultLoad = async (file, opts) => {
|
||||
loads.push({ file, opts });
|
||||
if (file.fail) throw new GeoImportError(file.fail);
|
||||
if (file.crash) throw new Error('boom');
|
||||
return {
|
||||
dataSource: { show: true },
|
||||
name: file.name,
|
||||
format: 'geojson',
|
||||
entityCount: 3,
|
||||
removed: file.removed || 0,
|
||||
revoke: () => revoked.push(file.name),
|
||||
};
|
||||
};
|
||||
const tool = initGeoFileImport({
|
||||
viewer,
|
||||
load: load || defaultLoad,
|
||||
document,
|
||||
...(navigation ? { navigation } : {}),
|
||||
...(subscribeMapSource ? { subscribeMapSource } : {}),
|
||||
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
|
||||
publish: (view) => {
|
||||
shown = view;
|
||||
},
|
||||
connect: (next) => {
|
||||
actions = next;
|
||||
return () => (actions = null);
|
||||
},
|
||||
});
|
||||
return {
|
||||
tool,
|
||||
els,
|
||||
document,
|
||||
viewer,
|
||||
added,
|
||||
removed,
|
||||
flights,
|
||||
revoked,
|
||||
loads,
|
||||
renders: () => renders,
|
||||
shown: () => shown,
|
||||
actions: () => actions,
|
||||
/** A control's change, as the view sends it. */
|
||||
act: (name, ...args) => actions?.[name]?.(...args),
|
||||
};
|
||||
}
|
||||
const file = (name, extra = {}) => ({ name, size: 10, ...extra });
|
||||
const rows = (t) => t.shown().rows;
|
||||
const action = (row, name) => row.actions.find((a) => a.action === name);
|
||||
// A row button's click, as the view sends it.
|
||||
const ROW_ACTIONS = {
|
||||
toggle: 'toggleGeoImport',
|
||||
fly: 'flyToGeoImport',
|
||||
remove: 'removeGeoImport',
|
||||
};
|
||||
const press = (t, row, name) => t.act(ROW_ACTIONS[name], row.id);
|
||||
|
||||
test('returns null without its markup or viewer', () => {
|
||||
const document = { getElementById: () => null };
|
||||
assert.equal(initGeoFileImport({ viewer: {}, document }), null);
|
||||
assert.equal(
|
||||
initGeoFileImport({
|
||||
viewer: null,
|
||||
document: { getElementById: () => new FakeElement() },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a pick imports each file', async () => {
|
||||
const t = setup();
|
||||
t.act('importGeoFiles', [file('a.geojson'), file('b.kml')]);
|
||||
for (let turn = 0; turn < 20 && t.added.length < 2; turn += 1)
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(t.added.length, 2);
|
||||
assert.notEqual(
|
||||
t.loads[0].opts.color,
|
||||
t.loads[1].opts.color,
|
||||
'each file gets its own color',
|
||||
);
|
||||
assert.equal(rows(t).length, 2);
|
||||
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
|
||||
assert.equal(t.shown().hint, 'Added 2 files.');
|
||||
assert.deepEqual(
|
||||
t.tool.list().map((f) => f.name),
|
||||
['a.geojson', 'b.kml'],
|
||||
);
|
||||
});
|
||||
|
||||
test('file names are published as given, for the view to show as text', async () => {
|
||||
const t = setup();
|
||||
await t.tool.importFiles([file('<img src=x onerror=alert(1)>.kml')]);
|
||||
assert.equal(rows(t)[0].name, '<img src=x onerror=alert(1)>.kml');
|
||||
});
|
||||
|
||||
test('row buttons hide, show, fly to and remove a file', async () => {
|
||||
const t = setup();
|
||||
await t.tool.importFiles([file('a.gpx')]);
|
||||
const [{ id }] = t.tool.list();
|
||||
const ds = t.added[0];
|
||||
const glyphClick = (name) => press(t, rows(t)[0], name);
|
||||
assert.equal(ds.show, true);
|
||||
glyphClick('toggle');
|
||||
assert.equal(ds.show, false);
|
||||
assert.equal(t.tool.list()[0].visible, false);
|
||||
assert.equal(action(rows(t)[0], 'toggle').pressed, 'false');
|
||||
assert.equal(action(rows(t)[0], 'toggle').icon, 'visibility_off');
|
||||
assert.equal(action(rows(t)[0], 'toggle').label, 'Show a.gpx');
|
||||
glyphClick('fly');
|
||||
assert.equal(ds.show, true, 'flying to a hidden file shows it');
|
||||
assert.equal(t.flights.at(-1), ds);
|
||||
glyphClick('remove');
|
||||
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
|
||||
assert.deepEqual(t.revoked, ['a.gpx']);
|
||||
assert.equal(t.tool.list().length, 0);
|
||||
assert.deepEqual(rows(t), []);
|
||||
assert.equal(t.tool.remove(id), false);
|
||||
});
|
||||
|
||||
test('a refused file says why and does not stop the rest of the batch', async () => {
|
||||
const t = setup();
|
||||
const out = await t.tool.importFiles([
|
||||
file('bad.kml', { fail: 'That KML file is not valid XML.' }),
|
||||
file('odd.gpx', { crash: true }),
|
||||
file('good.geojson'),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
out.map((o) => o.ok),
|
||||
[false, false, true],
|
||||
);
|
||||
assert.equal(out[0].error, 'That KML file is not valid XML.');
|
||||
assert.match(out[1].error, /could not be read\./);
|
||||
assert.match(
|
||||
t.shown().hint,
|
||||
/^Added 1 file\. 2 files could not be added \(bad\.kml: That KML file is not valid XML\.\)/,
|
||||
);
|
||||
assert.equal(t.tool.list().length, 1);
|
||||
});
|
||||
|
||||
test('the number of files on the globe is bounded', async () => {
|
||||
const t = setup();
|
||||
const many = Array.from({ length: MAX_IMPORTED_FILES + 2 }, (_, i) =>
|
||||
file(`f${i}.geojson`),
|
||||
);
|
||||
const out = await t.tool.importFiles(many);
|
||||
assert.equal(out.filter((o) => o.ok).length, MAX_IMPORTED_FILES);
|
||||
assert.match(out.at(-1).error, /At most 12 files/);
|
||||
assert.equal(
|
||||
t.loads.length,
|
||||
MAX_IMPORTED_FILES,
|
||||
'files over the limit are not even read',
|
||||
);
|
||||
});
|
||||
|
||||
test('dropping files on the globe imports them; other drags are left alone', async () => {
|
||||
const t = setup();
|
||||
const c = t.viewer.container;
|
||||
const files = { types: ['Files'], files: [file('drop.kmz')] };
|
||||
let ev = { dataTransfer: { types: ['text/plain'] } };
|
||||
t.document.emit('dragover', ev);
|
||||
c.emit('dragenter', { dataTransfer: files });
|
||||
assert.ok(t.document.body.classList.contains('gev-geo-import-drag'));
|
||||
c.emit('dragleave', { dataTransfer: files });
|
||||
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
|
||||
c.emit('dragenter', { dataTransfer: files });
|
||||
t.document.emit('drop', { dataTransfer: files });
|
||||
assert.ok(!t.document.body.classList.contains('gev-geo-import-drag'));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.deepEqual(
|
||||
t.tool.list().map((f) => f.name),
|
||||
['drop.kmz'],
|
||||
);
|
||||
});
|
||||
|
||||
test('stripped KML content is mentioned once, for the batch that had it', () => {
|
||||
assert.equal(
|
||||
summarize([{ name: 'a.kml', ok: true, removed: 3 }]),
|
||||
'Added 1 file. Some KML content was left out.',
|
||||
);
|
||||
assert.equal(
|
||||
summarize([{ name: 'b.geojson', ok: true, removed: 0 }]),
|
||||
'Added 1 file.',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy removes every file and listener and the window handle', async () => {
|
||||
globalThis.window = {};
|
||||
try {
|
||||
const t = setup();
|
||||
assert.equal(globalThis.window.__gevGeoImport, t.tool);
|
||||
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
|
||||
t.tool.destroy();
|
||||
assert.equal(t.removed.length, 2);
|
||||
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
|
||||
assert.equal(t.viewer.container.listeners.length, 0);
|
||||
assert.equal(t.document.listeners.length, 0);
|
||||
assert.equal(t.actions(), null, 'the view is disconnected');
|
||||
assert.deepEqual(t.shown(), { rows: [], hint: '' });
|
||||
assert.equal(globalThis.window.__gevGeoImport, undefined);
|
||||
assert.equal(t.tool.diagnostics().destroyed, true);
|
||||
} finally {
|
||||
delete globalThis.window;
|
||||
}
|
||||
});
|
||||
|
||||
test('a file that finishes loading after destroy is released, not added', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => (release = r));
|
||||
const revoked = [];
|
||||
const t = setup({
|
||||
load: async () => {
|
||||
await gate;
|
||||
return {
|
||||
dataSource: {},
|
||||
name: 'late',
|
||||
format: 'kml',
|
||||
entityCount: 1,
|
||||
removed: 0,
|
||||
revoke: () => revoked.push('late'),
|
||||
};
|
||||
},
|
||||
});
|
||||
const pending = t.tool.importFiles([file('late.kml')]);
|
||||
// Imports are queued: let the batch start loading before destroy lands.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
t.tool.destroy();
|
||||
release();
|
||||
await pending;
|
||||
assert.equal(t.added.length, 0);
|
||||
assert.deepEqual(revoked, ['late']);
|
||||
});
|
||||
|
||||
test('the application composition wires the import and owns its teardown', () => {
|
||||
const tools = fs.readFileSync(
|
||||
new URL('../app/tools.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const drawAt = tools.indexOf('const drawTool = initDrawTool({');
|
||||
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
|
||||
assert.ok(importAt > drawAt && drawAt >= 0);
|
||||
// Fly-to goes through the application shell's navigation policy.
|
||||
assert.match(
|
||||
tools.slice(importAt, importAt + 200),
|
||||
/navigation: styleManager/,
|
||||
);
|
||||
assert.ok(tools.indexOf('defer(() => geoFileImport?.destroy());') > importAt);
|
||||
// The list renders from the store and its controls answer through it.
|
||||
assert.match(
|
||||
tools.slice(importAt, importAt + 400),
|
||||
/publish: \(view\) => displayStore\.publishGeoImport\(view\),\s*connect: connectDisplay,/,
|
||||
);
|
||||
const markup = fs.readFileSync(
|
||||
new URL('./views/panels/GeoImport.vue', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
for (const id of [
|
||||
'geo-import-button',
|
||||
'geo-import-input',
|
||||
'geo-import-list',
|
||||
'geo-import-hint',
|
||||
])
|
||||
assert.ok(
|
||||
markup.includes(`id="${id}"`),
|
||||
`${id} missing from the Import template`,
|
||||
);
|
||||
});
|
||||
|
||||
test('two imports at once never pass the file cap: batches run one after another', async () => {
|
||||
const t = setup();
|
||||
const first = t.tool.importFiles(
|
||||
Array.from({ length: 8 }, (_, i) => file(`a${i}.geojson`)),
|
||||
);
|
||||
const second = t.tool.importFiles(
|
||||
Array.from({ length: 8 }, (_, i) => file(`b${i}.geojson`)),
|
||||
);
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
assert.equal(t.tool.list().length, MAX_IMPORTED_FILES);
|
||||
assert.equal(a.filter((o) => o.ok).length, 8);
|
||||
assert.equal(b.filter((o) => o.ok).length, MAX_IMPORTED_FILES - 8);
|
||||
assert.equal(b.filter((o) => !o.ok).length, 16 - MAX_IMPORTED_FILES);
|
||||
// The second batch's loads started only after the first batch finished.
|
||||
assert.deepEqual(
|
||||
t.loads.map((l) => l.file.name.slice(0, 1)).join(''),
|
||||
'a'.repeat(8) + 'b'.repeat(MAX_IMPORTED_FILES - 8),
|
||||
);
|
||||
});
|
||||
|
||||
function navigationDouble({ immediate = true, reassert = true } = {}) {
|
||||
const calls = [];
|
||||
let generation = 0;
|
||||
return {
|
||||
calls,
|
||||
runImmediateNavigation(noun, navigate) {
|
||||
calls.push(['immediate', noun]);
|
||||
return immediate ? navigate(++generation) : false;
|
||||
},
|
||||
beginDeferredLocationNavigation() {
|
||||
calls.push(['begin']);
|
||||
return ++generation;
|
||||
},
|
||||
reassertDeferredLocationNavigation(g) {
|
||||
calls.push(['reassert', g]);
|
||||
return reassert;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a direct camera gesture cancels the fly-to of a file still being read', async () => {
|
||||
for (const gesture of ['pointerdown', 'wheel']) {
|
||||
let release;
|
||||
const t = setup({
|
||||
load: async () => {
|
||||
await new Promise((r) => (release = r));
|
||||
return {
|
||||
name: 'slow.geojson',
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
dataSource: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = t.tool.importFiles([file('slow.geojson')]);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
t.viewer.container.emit(gesture);
|
||||
release();
|
||||
await result;
|
||||
assert.equal(t.added.length, 1);
|
||||
assert.equal(t.flights.length, 0);
|
||||
t.tool.destroy();
|
||||
assert.equal(t.document.listeners.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('dropping on panel controls is accepted and file drags prevent browser navigation', async () => {
|
||||
const t = setup();
|
||||
let prevented = 0;
|
||||
const event = {
|
||||
target: t.els['geo-import-button'],
|
||||
dataTransfer: { types: ['Files'], files: [file('panel.kml')] },
|
||||
preventDefault: () => prevented++,
|
||||
};
|
||||
t.document.emit('dragover', event);
|
||||
t.document.emit('drop', event);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(prevented, 2);
|
||||
assert.equal(t.tool.list()[0].name, 'panel.kml');
|
||||
t.tool.destroy();
|
||||
assert.equal(t.document.listeners.length, 0);
|
||||
});
|
||||
|
||||
test('ground refresh coalesces map changes and releases its subscription on teardown', async () => {
|
||||
let changed,
|
||||
unsubscribed = 0,
|
||||
refreshed = 0;
|
||||
const t = setup({
|
||||
subscribeMapSource: (listener) => {
|
||||
changed = listener;
|
||||
return () => unsubscribed++;
|
||||
},
|
||||
load: async () => ({
|
||||
name: 'point.geojson',
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
dataSource: {},
|
||||
refreshGround: async () => refreshed++,
|
||||
}),
|
||||
});
|
||||
await t.tool.importFiles([file('point.geojson')]);
|
||||
changed();
|
||||
changed();
|
||||
changed();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
assert.equal(refreshed, 1);
|
||||
t.tool.destroy();
|
||||
changed();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
assert.equal(refreshed, 1);
|
||||
assert.equal(unsubscribed, 1);
|
||||
});
|
||||
|
||||
test('Fly to is an immediate navigation that Cockpit can refuse', async () => {
|
||||
const flown = [];
|
||||
const nav = navigationDouble({ immediate: false });
|
||||
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
|
||||
// Batch auto-fly is deferred: stamped at the start, re-asserted before flying.
|
||||
await t.tool.importFiles([file('a.geojson')]);
|
||||
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
|
||||
assert.equal(flown.length, 1);
|
||||
// The button goes through the immediate route; refused, nothing flies.
|
||||
const id = t.tool.list()[0].id;
|
||||
assert.equal(t.tool.flyTo(id), false);
|
||||
assert.deepEqual(nav.calls.at(-1), ['immediate', 'file']);
|
||||
assert.equal(flown.length, 1);
|
||||
const nav2 = navigationDouble();
|
||||
const t2 = setup({
|
||||
navigation: nav2,
|
||||
flyToSource: (v, ds) => flown.push(ds),
|
||||
});
|
||||
await t2.tool.importFiles([file('b.geojson')]);
|
||||
assert.equal(t2.tool.flyTo(t2.tool.list()[0].id), true);
|
||||
assert.equal(flown.length, 3);
|
||||
});
|
||||
|
||||
test('a slow import does not fly once something newer took the camera', async () => {
|
||||
let release;
|
||||
const gate = new Promise((r) => (release = r));
|
||||
const flown = [];
|
||||
const nav = navigationDouble({ reassert: false });
|
||||
const t = setup({
|
||||
navigation: nav,
|
||||
flyToSource: (v, ds) => flown.push(ds),
|
||||
load: async (f) => {
|
||||
await gate;
|
||||
return {
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const pending = t.tool.importFiles([file('slow.geojson')]);
|
||||
release();
|
||||
const [out] = await pending;
|
||||
assert.equal(out.ok, true);
|
||||
assert.deepEqual(nav.calls, [['begin'], ['reassert', 1]]);
|
||||
assert.equal(flown.length, 0);
|
||||
});
|
||||
|
||||
test('a queued import cannot fly over navigation that happened while it waited', async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let generation = 0;
|
||||
const flown = [];
|
||||
const nav = {
|
||||
beginDeferredLocationNavigation: () => ++generation,
|
||||
reassertDeferredLocationNavigation: (stamp) => stamp === generation,
|
||||
};
|
||||
const t = setup({
|
||||
navigation: nav,
|
||||
flyToSource: (v, ds) => flown.push(ds),
|
||||
load: async (f) => {
|
||||
if (f.name === 'first.geojson') await gate;
|
||||
return {
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: 'geojson',
|
||||
entityCount: 1,
|
||||
revoke() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const first = t.tool.importFiles([file('first.geojson')]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const queued = t.tool.importFiles([file('queued.geojson')]);
|
||||
generation++; // A newer user camera action while the second batch is waiting.
|
||||
release();
|
||||
const results = await Promise.all([first, queued]);
|
||||
assert.ok(results.every(([outcome]) => outcome.ok));
|
||||
assert.equal(t.tool.list().length, 2);
|
||||
assert.equal(flown.length, 0);
|
||||
});
|
||||
|
||||
test('a refused deferred stamp (Cockpit) imports without flying', async () => {
|
||||
const flown = [];
|
||||
const nav = navigationDouble();
|
||||
nav.beginDeferredLocationNavigation = () => false;
|
||||
nav.reassertDeferredLocationNavigation = () => {
|
||||
throw new Error('must not re-assert a refused stamp');
|
||||
};
|
||||
const t = setup({ navigation: nav, flyToSource: (v, ds) => flown.push(ds) });
|
||||
const [out] = await t.tool.importFiles([file('a.geojson')]);
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal(flown.length, 0);
|
||||
});
|
||||
|
||||
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
|
||||
const t = setup({
|
||||
load: async (f) => ({
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: f.name.endsWith('.kml') ? 'kml' : 'gpx',
|
||||
entityCount: 2,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
}),
|
||||
});
|
||||
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
|
||||
const [kml, gpx] = rows(t);
|
||||
assert.equal(kml.ownStyles, true);
|
||||
assert.match(kml.title, /drawn with its own styles/);
|
||||
assert.equal(gpx.ownStyles, false);
|
||||
assert.ok(gpx.color);
|
||||
});
|
||||
|
||||
test('the map-object count in a row title keeps digit grouping', async () => {
|
||||
const t = setup({
|
||||
load: async (f) => ({
|
||||
dataSource: {},
|
||||
name: f.name,
|
||||
format: 'geojson',
|
||||
entityCount: 12345,
|
||||
removed: 0,
|
||||
revoke() {},
|
||||
}),
|
||||
});
|
||||
await t.tool.importFiles([file('big.geojson')]);
|
||||
const [row] = rows(t);
|
||||
assert.match(row.title, /12,345 map objects/);
|
||||
t.tool.destroy();
|
||||
});
|
||||
|
||||
test('a refusal shows its counts in the language’s numerals, not the browser’s', async () => {
|
||||
const t = setup({
|
||||
load: async () => {
|
||||
throw new GeoImportError(
|
||||
'That file has 60,000 map objects; the limit is 50,000.',
|
||||
'That file has {count} map objects; the limit is {limit}.',
|
||||
{ count: 60000, limit: 50000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
const [out] = await t.tool.importFiles([file('huge.kml')]);
|
||||
assert.equal(
|
||||
out.error,
|
||||
'That file has 60,000 map objects; the limit is 50,000.',
|
||||
);
|
||||
t.tool.destroy();
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
// src/ui/globeActions.test.mjs — the top-center globe actions call their
|
||||
// owners through the globe actions store, and a clear under way keeps its
|
||||
// button focusable and marks it busy.
|
||||
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 { useGlobeActionsStore } from './stores/globeActions.js';
|
||||
import GlobeActions from './views/panels/GlobeActions.vue';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
|
||||
function mount(t) {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useGlobeActionsStore(pinia);
|
||||
const calls = [];
|
||||
const token = store.connect({
|
||||
clear: () => calls.push('clear'),
|
||||
share: () => calls.push('share'),
|
||||
tilt: () => calls.push('tilt'),
|
||||
northUp: () => calls.push('northUp'),
|
||||
reset: () => calls.push('reset'),
|
||||
});
|
||||
const app = createApp(GlobeActions).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
return { calls, store, token };
|
||||
}
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
test('every button calls its owner', (t) => {
|
||||
const { calls } = mount(t);
|
||||
$('clear-selected-layers').click();
|
||||
$('share-btn').click();
|
||||
$('tilt-map-view').click();
|
||||
$('north-up-view').click();
|
||||
$('reset-globe-view').click();
|
||||
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp', 'reset']);
|
||||
});
|
||||
|
||||
test('the tilt and compass buttons show the published camera', async (t) => {
|
||||
const { store } = mount(t);
|
||||
const tilt = $('tilt-map-view');
|
||||
const north = $('north-up-view');
|
||||
assert.equal(tilt.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(tilt.getAttribute('aria-label'), 'Tilt map to oblique view');
|
||||
assert.equal(north.getAttribute('aria-label'), 'Reset map to north up');
|
||||
assert.equal(north.hasAttribute('data-north-up'), false);
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '');
|
||||
|
||||
store.publishCamera({ tilted: true, heading: 0 });
|
||||
await nextTick();
|
||||
assert.equal(tilt.getAttribute('aria-pressed'), 'true');
|
||||
assert.equal(
|
||||
tilt.getAttribute('aria-label'),
|
||||
'Return map to straight-down view',
|
||||
);
|
||||
assert.equal(north.getAttribute('data-north-up'), 'true');
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '0deg');
|
||||
|
||||
store.publishCamera({ tilted: false, heading: 47 });
|
||||
await nextTick();
|
||||
assert.equal(tilt.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(north.getAttribute('data-north-up'), 'false');
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '47deg');
|
||||
assert.equal(
|
||||
north.getAttribute('aria-label'),
|
||||
'Reset map to north up. Current heading 47 degrees',
|
||||
);
|
||||
|
||||
// A disposed shell hands the next one an unmeasured compass.
|
||||
store.publishCamera(null);
|
||||
await nextTick();
|
||||
assert.equal(north.hasAttribute('data-north-up'), false);
|
||||
assert.equal(north.style.getPropertyValue('--camera-heading'), '');
|
||||
});
|
||||
|
||||
test('a clear under way marks the button busy and keeps it focusable', async (t) => {
|
||||
const { store, token } = mount(t);
|
||||
const clear = $('clear-selected-layers');
|
||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||
store.publishClearBusy(token, true);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'true');
|
||||
assert.equal(clear.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(
|
||||
clear.getAttribute('aria-label'),
|
||||
'Clearing selected data layers',
|
||||
);
|
||||
assert.equal(clear.disabled, false);
|
||||
store.publishClearBusy(token, false);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'false');
|
||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||
// The button shows the clear of the owner that answers it: a newer one's,
|
||||
// then the older one's again once the newer goes.
|
||||
store.publishClearBusy(token, true);
|
||||
const newer = store.connect({ clear() {} });
|
||||
await nextTick();
|
||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||
store.publishClearBusy(newer, false);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'false');
|
||||
store.detach(newer);
|
||||
await nextTick();
|
||||
assert.equal(clear.getAttribute('aria-busy'), 'true');
|
||||
// A shell disposed mid-clear hands the next one a bar that is not busy.
|
||||
store.detach(token);
|
||||
await nextTick();
|
||||
assert.equal(clear.hasAttribute('aria-busy'), false);
|
||||
assert.equal(clear.hasAttribute('aria-disabled'), false);
|
||||
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
|
||||
});
|
||||
|
||||
test('a detached owner no longer answers', (t) => {
|
||||
const { calls, store } = mount(t);
|
||||
const token = store.connect({ share: () => calls.push('newer share') });
|
||||
$('share-btn').click();
|
||||
store.detach(token);
|
||||
$('share-btn').click();
|
||||
assert.deepEqual(calls, ['newer share', 'share']);
|
||||
});
|
||||
|
||||
test('the same camera state again changes nothing', () => {
|
||||
const store = useGlobeActionsStore(createPinia());
|
||||
store.publishCamera({ tilted: true, heading: 12 });
|
||||
const kept = store.camera;
|
||||
store.publishCamera({ tilted: true, heading: 12 });
|
||||
assert.equal(store.camera, kept);
|
||||
store.publishCamera({ tilted: true, heading: 13 });
|
||||
assert.notEqual(store.camera, kept);
|
||||
assert.deepEqual(store.camera, { tilted: true, heading: 13 });
|
||||
});
|
||||
|
||||
test('a reset under way relabels the reset button for its owner', async (t) => {
|
||||
const { store, token } = mount(t);
|
||||
const reset = $('reset-globe-view');
|
||||
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
|
||||
store.publishResetting(token, true);
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
reset.getAttribute('aria-label'),
|
||||
'Resetting to full globe view',
|
||||
);
|
||||
assert.equal(store.clearBusy, null, 'a reset is not a clear');
|
||||
store.detach(token);
|
||||
await nextTick();
|
||||
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
|
||||
});
|
||||
|
||||
test('the cockpit reset asks the reset owner and shows its reset', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const store = useGlobeActionsStore(pinia);
|
||||
const calls = [];
|
||||
const token = store.connect({ reset: () => calls.push('reset') });
|
||||
const app = createApp(CockpitView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
const reset = $('cockpit-reset-globe');
|
||||
assert.equal(
|
||||
reset.getAttribute('aria-label'),
|
||||
'Reset cockpit to full globe view',
|
||||
);
|
||||
reset.click();
|
||||
assert.deepEqual(calls, ['reset']);
|
||||
store.publishResetting(token, true);
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
reset.getAttribute('aria-label'),
|
||||
'Resetting cockpit to full globe view',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { watch } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import {
|
||||
bindLocale,
|
||||
getCatalog,
|
||||
getLocale,
|
||||
notifyLocaleChange,
|
||||
} from '../i18n/index.js';
|
||||
import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js';
|
||||
|
||||
let pageI18n = null;
|
||||
|
||||
// Catalogs are flat and keyed by English text, which holds dots.
|
||||
const resolveFlat = (messages, key) =>
|
||||
Object.hasOwn(messages, key) ? messages[key] : null;
|
||||
|
||||
/**
|
||||
* The page's vue-i18n instance, installed in every application view. It
|
||||
* holds the active locale and the catalogs; Core's t(), tn() and the Intl
|
||||
* formatters read the locale from it, so a view that calls them while
|
||||
* rendering re-renders when the locale changes. Views translate through
|
||||
* t()/tn() rather than vue-i18n's own t: English is the key, `context` picks
|
||||
* an entry, and plural forms follow Intl.PluralRules (docs/I18N.md).
|
||||
* @returns {import('vue-i18n').I18n}
|
||||
*/
|
||||
export function applicationI18n() {
|
||||
if (pageI18n) return pageI18n;
|
||||
const messages = {};
|
||||
for (const locale of LOCALES)
|
||||
if (locale !== DEFAULT_LOCALE) messages[locale] = getCatalog(locale);
|
||||
pageI18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: false,
|
||||
locale: getLocale(),
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages,
|
||||
messageResolver: resolveFlat,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
// vue-i18n disposes its instance when an app it is installed in unmounts;
|
||||
// the page's outlives every view, so an unmounted panel keeps it.
|
||||
const install = pageI18n.install;
|
||||
pageI18n.install = (app, ...options) => {
|
||||
const unmount = app.unmount;
|
||||
const installed = install.call(pageI18n, app, ...options);
|
||||
app.unmount = unmount;
|
||||
return installed;
|
||||
};
|
||||
bindLocale(pageI18n.global.locale);
|
||||
// Code that translated text itself shows it again, whichever way the
|
||||
// locale changed (setLocale or the ref).
|
||||
watch(
|
||||
() => getLocale(),
|
||||
() => notifyLocaleChange(),
|
||||
{ flush: 'sync' },
|
||||
);
|
||||
return pageI18n;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../testSupport/vueDom.mjs';
|
||||
|
||||
let vue;
|
||||
let i18n;
|
||||
let applicationI18n;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
vue = await import('vue');
|
||||
i18n = await import('../i18n/index.js');
|
||||
({ applicationI18n } = await import('./i18n.js'));
|
||||
});
|
||||
after(() => {
|
||||
i18n.setLocale('en');
|
||||
uninstall();
|
||||
});
|
||||
|
||||
test('vue-i18n takes the locale set before it and holds the catalogs', () => {
|
||||
i18n.setLocale('ru');
|
||||
const { global } = applicationI18n();
|
||||
assert.equal(applicationI18n(), applicationI18n());
|
||||
assert.equal(global.locale.value, 'ru');
|
||||
assert.equal(global.te('Settings'), true);
|
||||
assert.equal(global.tm('Settings'), 'Настройки');
|
||||
global.locale.value = 'de';
|
||||
assert.equal(i18n.getLocale(), 'de');
|
||||
global.locale.value = 'ru_RU';
|
||||
assert.equal(i18n.getLocale(), 'ru');
|
||||
assert.equal(i18n.t('Settings'), 'Настройки');
|
||||
assert.equal(i18n.formatNumber(1234.5), '1\u00a0234,5');
|
||||
global.locale.value = 'xx';
|
||||
assert.equal(i18n.getLocale(), 'en');
|
||||
i18n.setLocale('en');
|
||||
assert.equal(global.locale.value, 'en');
|
||||
});
|
||||
|
||||
test('a view that translates re-renders when the locale changes', async () => {
|
||||
const { createApp, h, nextTick } = vue;
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const app = createApp({
|
||||
render: () =>
|
||||
h('p', [
|
||||
i18n.t('Settings'),
|
||||
' / ',
|
||||
i18n.tn(
|
||||
3,
|
||||
'POWER UP · {count} KEY WAITING',
|
||||
'POWER UP · {count} KEYS WAITING',
|
||||
),
|
||||
]),
|
||||
}).use(applicationI18n());
|
||||
app.mount(host);
|
||||
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
|
||||
i18n.setLocale('ru');
|
||||
await nextTick();
|
||||
assert.equal(host.textContent, 'Настройки / ЗАПУСК · 3 КЛЮЧА В ОЖИДАНИИ');
|
||||
i18n.setLocale('en');
|
||||
await nextTick();
|
||||
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
|
||||
app.unmount();
|
||||
host.remove();
|
||||
});
|
||||
|
||||
test('unmounting one view keeps the page instance for the others', async () => {
|
||||
const { createApp, h, nextTick } = vue;
|
||||
const page = applicationI18n();
|
||||
let disposed = 0;
|
||||
const dispose = page.dispose;
|
||||
page.dispose = () => {
|
||||
disposed += 1;
|
||||
dispose.call(page);
|
||||
};
|
||||
const mountView = () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
const app = createApp({ render: () => h('p', i18n.t('Settings')) });
|
||||
app.use(page).mount(host);
|
||||
return { app, host };
|
||||
};
|
||||
const first = mountView();
|
||||
const second = mountView();
|
||||
first.app.unmount();
|
||||
assert.equal(disposed, 0);
|
||||
i18n.setLocale('ru');
|
||||
await nextTick();
|
||||
assert.equal(second.host.textContent, 'Настройки');
|
||||
const third = mountView();
|
||||
assert.equal(third.host.textContent, 'Настройки');
|
||||
i18n.setLocale('en');
|
||||
second.app.unmount();
|
||||
third.app.unmount();
|
||||
page.dispose = dispose;
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Box selection for the Recent Imagery layer: press on the world, drag, and
|
||||
* release to hand the layer a degrees box. Follows the draw tool's ownership
|
||||
* rules — the pointer is claimed for the session, the viewer's own click
|
||||
* actions are borrowed and given back, and `destroy()` returns everything —
|
||||
* and freezes the camera only while a drag is in progress.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { pickWorldFromScreen } from '../annotations/annotationResolver.js';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
} from '../data/inputOwnership.js';
|
||||
import { validateBox } from '../layers/recentImagery/model.js';
|
||||
import { msg } from '../i18n/index.js';
|
||||
|
||||
/** The id this tool claims the pointer under. */
|
||||
export const IMAGERY_BOX_POINTER_OWNER = 'recent-imagery-box';
|
||||
|
||||
/**
|
||||
* Wire the box tool.
|
||||
* @param {{ viewer: object, onBox: (box: object) => void, onCancel?: (reason: string, message?: string, box?: object) => void, onActive?: (active: boolean) => void, onEscape?: () => boolean, cesium?: object, pickWorld?: Function, documentRef?: object }} deps
|
||||
* `onEscape` runs before the tool cancels on Escape; returning `true` claims
|
||||
* the key (it is still consumed) and leaves the tool armed.
|
||||
* @returns {{ start: () => boolean, cancel: (reason?: string) => void, isActive: () => boolean, destroy: () => Promise<void> } | null}
|
||||
*/
|
||||
export function initImageryBoxTool({
|
||||
viewer,
|
||||
onBox,
|
||||
onCancel,
|
||||
onActive,
|
||||
onEscape,
|
||||
cesium = Cesium,
|
||||
pickWorld = pickWorldFromScreen,
|
||||
documentRef = globalThis.document,
|
||||
} = {}) {
|
||||
if (!viewer?.scene || typeof onBox !== 'function') return null;
|
||||
|
||||
let active = false;
|
||||
let destroyed = false;
|
||||
/** Drag corners in degrees; `anchor` is null while no drag is live. */
|
||||
let anchor = null;
|
||||
let corner = null;
|
||||
let handler = null;
|
||||
let lease = null;
|
||||
let savedSingleClick = null;
|
||||
let savedDoubleClick = null;
|
||||
let savedCameraInputs = null;
|
||||
|
||||
const dragBox = () =>
|
||||
anchor && {
|
||||
west: Math.min(anchor.lon, corner.lon),
|
||||
south: Math.min(anchor.lat, corner.lat),
|
||||
east: Math.max(anchor.lon, corner.lon),
|
||||
north: Math.max(anchor.lat, corner.lat),
|
||||
};
|
||||
|
||||
const dataSource = new cesium.CustomDataSource(
|
||||
'gev-recent-imagery-box-preview',
|
||||
);
|
||||
let attaching = Promise.resolve(viewer.dataSources.add(dataSource)).catch(
|
||||
() => null,
|
||||
);
|
||||
const stroke = cesium.Color.fromCssColorString('#8be9ff');
|
||||
const previewRectangle = () => {
|
||||
const box = dragBox();
|
||||
return box
|
||||
? cesium.Rectangle.fromDegrees(box.west, box.south, box.east, box.north)
|
||||
: undefined;
|
||||
};
|
||||
const previewOutline = () => {
|
||||
const box = dragBox();
|
||||
if (!box) return [];
|
||||
return cesium.Cartesian3.fromDegreesArray([
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
box.west,
|
||||
box.north,
|
||||
box.west,
|
||||
box.south,
|
||||
]);
|
||||
};
|
||||
const fill = dataSource.entities.add({
|
||||
show: false,
|
||||
rectangle: {
|
||||
coordinates: new cesium.CallbackProperty(previewRectangle, false),
|
||||
material: stroke.withAlpha(0.12),
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
// A rectangle graphic cannot draw a dashed edge on the ground, so the
|
||||
// outline is its own draped polyline traced round the same corners.
|
||||
const outline = dataSource.entities.add({
|
||||
show: false,
|
||||
polyline: {
|
||||
positions: new cesium.CallbackProperty(previewOutline, false),
|
||||
width: 2,
|
||||
clampToGround: true,
|
||||
classificationType: cesium.ClassificationType.BOTH,
|
||||
material: new cesium.PolylineDashMaterialProperty({
|
||||
color: stroke.withAlpha(0.95),
|
||||
dashLength: 12,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const requestRender = () => viewer.scene.requestRender?.();
|
||||
const setPreview = (visible) => {
|
||||
fill.show = visible;
|
||||
outline.show = visible;
|
||||
requestRender();
|
||||
};
|
||||
const worldAt = (position) => {
|
||||
if (!position) return null;
|
||||
const canvas = viewer.scene.canvas;
|
||||
const w = canvas?.clientWidth || canvas?.width || 1;
|
||||
const h = canvas?.clientHeight || canvas?.height || 1;
|
||||
const point = pickWorld(viewer, position.x / w, position.y / h);
|
||||
const lon = Number(point?.lon);
|
||||
const lat = Number(point?.lat);
|
||||
return Number.isFinite(lon) && Number.isFinite(lat) ? { lon, lat } : null;
|
||||
};
|
||||
const cameraController = () => viewer.scene.screenSpaceCameraController;
|
||||
|
||||
function freezeCamera() {
|
||||
const controller = cameraController();
|
||||
if (!controller || savedCameraInputs !== null) return;
|
||||
savedCameraInputs = controller.enableInputs;
|
||||
controller.enableInputs = false;
|
||||
}
|
||||
function thawCamera() {
|
||||
const controller = cameraController();
|
||||
if (controller && savedCameraInputs !== null)
|
||||
controller.enableInputs = savedCameraInputs;
|
||||
savedCameraInputs = null;
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
anchor = null;
|
||||
corner = null;
|
||||
thawCamera();
|
||||
setPreview(false);
|
||||
}
|
||||
|
||||
const onDown = (event) => {
|
||||
if (!active || destroyed || anchor) return;
|
||||
const point = worldAt(event.position);
|
||||
if (!point) {
|
||||
onCancel?.('sky', msg('Press on the ground, not the sky'));
|
||||
return;
|
||||
}
|
||||
anchor = point;
|
||||
corner = point;
|
||||
freezeCamera();
|
||||
setPreview(true);
|
||||
};
|
||||
const onMove = (event) => {
|
||||
const point = anchor && !destroyed && worldAt(event.endPosition);
|
||||
if (!point) return;
|
||||
corner = point;
|
||||
requestRender();
|
||||
};
|
||||
const onUp = (event) => {
|
||||
if (!anchor || destroyed) return;
|
||||
corner = worldAt(event.position) || corner;
|
||||
const box = dragBox();
|
||||
const result = validateBox(box);
|
||||
if (result.reason === 'degenerate')
|
||||
result.message = msg('Drag to size the box');
|
||||
endDrag();
|
||||
setActive(false);
|
||||
if (result.ok) onBox(result.box);
|
||||
else onCancel?.(result.reason, result.message, box);
|
||||
};
|
||||
// Escape is taken from a document capture listener, ahead of every other
|
||||
// handler, so the interceptor runs first: when it claims the key (the
|
||||
// panel exits a live comparison), the tool stays armed and the key still
|
||||
// goes no further; otherwise Escape cancels the session.
|
||||
const onKey = (event) => {
|
||||
if (!active || destroyed || event.key !== 'Escape') return;
|
||||
event.preventDefault?.();
|
||||
event.stopImmediatePropagation?.();
|
||||
if (onEscape?.() === true) return;
|
||||
cancel('escape');
|
||||
};
|
||||
|
||||
function bindScene() {
|
||||
if (handler) return;
|
||||
handler = new cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction(onDown, cesium.ScreenSpaceEventType.LEFT_DOWN);
|
||||
handler.setInputAction(onMove, cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
||||
handler.setInputAction(onUp, cesium.ScreenSpaceEventType.LEFT_UP);
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock) {
|
||||
savedSingleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK) || null;
|
||||
savedDoubleClick =
|
||||
stock.getInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK) ||
|
||||
null;
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
stock.removeInputAction(cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
||||
}
|
||||
documentRef?.addEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.add?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function releaseScene() {
|
||||
if (handler) {
|
||||
handler.destroy();
|
||||
handler = null;
|
||||
}
|
||||
const stock = viewer.screenSpaceEventHandler;
|
||||
if (stock && savedSingleClick) {
|
||||
stock.setInputAction(
|
||||
savedSingleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
}
|
||||
if (stock && savedDoubleClick) {
|
||||
stock.setInputAction(
|
||||
savedDoubleClick,
|
||||
cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK,
|
||||
);
|
||||
}
|
||||
savedSingleClick = null;
|
||||
savedDoubleClick = null;
|
||||
documentRef?.removeEventListener?.('keydown', onKey, true);
|
||||
documentRef?.body?.classList?.remove?.('gev-imagery-box');
|
||||
}
|
||||
|
||||
function setActive(next) {
|
||||
if (next === active) return active;
|
||||
if (next) {
|
||||
lease = claimPointer(IMAGERY_BOX_POINTER_OWNER);
|
||||
if (!lease) {
|
||||
onCancel?.(
|
||||
'pointer-busy',
|
||||
`${pointerOwner()} is using the pointer — close it first`,
|
||||
);
|
||||
return active;
|
||||
}
|
||||
active = true;
|
||||
bindScene();
|
||||
} else {
|
||||
if (anchor) endDrag();
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
active = false;
|
||||
}
|
||||
onActive?.(active);
|
||||
return active;
|
||||
}
|
||||
|
||||
function cancel(reason = 'cancel') {
|
||||
if (!active || destroyed) return;
|
||||
setActive(false);
|
||||
onCancel?.(reason);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Begin a box session. False when the pointer is held elsewhere. */
|
||||
start() {
|
||||
return !destroyed && setActive(true);
|
||||
},
|
||||
cancel,
|
||||
isActive() {
|
||||
return active;
|
||||
},
|
||||
/** Returns everything; resolves once the preview is detached. */
|
||||
destroy() {
|
||||
if (destroyed) return attaching;
|
||||
if (active) setActive(false);
|
||||
destroyed = true;
|
||||
releaseScene();
|
||||
releasePointer(lease);
|
||||
lease = null;
|
||||
thawCamera();
|
||||
dataSource.entities.removeAll();
|
||||
attaching = attaching.then(() => {
|
||||
try {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
} catch {
|
||||
/* viewer already disposed */
|
||||
}
|
||||
});
|
||||
return attaching;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
claimPointer,
|
||||
pointerOwner,
|
||||
releasePointer,
|
||||
resetPointerOwnership,
|
||||
} from '../data/inputOwnership.js';
|
||||
import {
|
||||
IMAGERY_BOX_POINTER_OWNER,
|
||||
initImageryBoxTool,
|
||||
} from './imageryBoxTool.js';
|
||||
import { boxToolFakes } from '../layers/recentImagery/testDoubles.mjs';
|
||||
|
||||
function fixture(options = {}) {
|
||||
resetPointerOwnership();
|
||||
const fakes = boxToolFakes();
|
||||
const boxes = [];
|
||||
const cancels = [];
|
||||
const actives = [];
|
||||
const tool = initImageryBoxTool({
|
||||
viewer: fakes.viewer,
|
||||
cesium: fakes.cesium,
|
||||
pickWorld: fakes.pickWorld,
|
||||
documentRef: fakes.documentRef,
|
||||
onBox: (box) => boxes.push(box),
|
||||
onCancel: (reason, message) => cancels.push([reason, message]),
|
||||
onActive: (active) => actives.push(active),
|
||||
...options,
|
||||
});
|
||||
const camera = fakes.viewer.scene.screenSpaceCameraController;
|
||||
const stockAction = (type) =>
|
||||
fakes.viewer.screenSpaceEventHandler.getInputAction(fakes.types[type]);
|
||||
const preview = () =>
|
||||
fakes.viewer.dataSources.sources[0]?.entities.values.every((e) => e.show);
|
||||
return {
|
||||
...fakes,
|
||||
tool,
|
||||
boxes,
|
||||
cancels,
|
||||
actives,
|
||||
camera,
|
||||
stockAction,
|
||||
preview,
|
||||
};
|
||||
}
|
||||
|
||||
test('a session claims the pointer, borrows the stock clicks, freezes the camera only mid-drag and returns everything with the box', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.tool.start(), true);
|
||||
assert.equal(pointerOwner(), IMAGERY_BOX_POINTER_OWNER);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), undefined);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), undefined);
|
||||
assert.ok(f.documentRef.classes.has('gev-imagery-box'));
|
||||
assert.equal(f.camera.enableInputs, true, 'not frozen before the press');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.camera.enableInputs, false);
|
||||
assert.equal(f.preview(), true);
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 250 } });
|
||||
f.fire('MOUSE_MOVE', { endPosition: { x: 500, y: 10 } }); // sky: ignored
|
||||
f.fire('LEFT_UP', { position: { x: 500, y: 250 } });
|
||||
assert.equal(f.boxes.length, 1);
|
||||
const { west, south, east, north } = f.boxes[0];
|
||||
assert.deepEqual(
|
||||
[west, south, east, north].map((v) => Math.round(v * 1e6) / 1e6),
|
||||
[-97.85, 30.3, -97.8, 30.35],
|
||||
);
|
||||
assert.deepEqual(f.cancels, []);
|
||||
assert.equal(f.tool.isActive(), false);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.stockAction('LEFT_DOUBLE_CLICK'), f.originalDouble);
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(f.preview(), false);
|
||||
assert.equal(f.documentRef.classes.has('gev-imagery-box'), false);
|
||||
assert.equal(f.documentRef.listeners.length, 0);
|
||||
assert.equal(f.handler(), null);
|
||||
assert.deepEqual(f.actives, [true, false]);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a sky press, a click without a drag and Escape each say why', async () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 10 } });
|
||||
assert.deepEqual(f.cancels.at(-1), [
|
||||
'sky',
|
||||
'Press on the ground, not the sky',
|
||||
]);
|
||||
assert.equal(f.tool.isActive(), true, 'a sky press keeps the session');
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
f.fire('LEFT_UP', { position: { x: 250, y: 125 } });
|
||||
assert.deepEqual(f.cancels.at(-1), ['degenerate', 'Drag to size the box']);
|
||||
f.tool.start();
|
||||
assert.equal(f.key('Escape').prevented, true);
|
||||
assert.deepEqual(f.cancels.at(-1), ['escape', undefined]);
|
||||
assert.equal(pointerOwner(), null);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('an Escape interceptor that claims the key leaves the tool armed; the next Escape cancels', async () => {
|
||||
let claim = true;
|
||||
let asked = 0;
|
||||
const f = fixture({
|
||||
onEscape: () => {
|
||||
asked += 1;
|
||||
return claim;
|
||||
},
|
||||
});
|
||||
f.tool.start();
|
||||
const first = f.key('Escape');
|
||||
assert.equal(asked, 1);
|
||||
assert.equal(
|
||||
first.prevented && first.stopped,
|
||||
true,
|
||||
'the key goes no further',
|
||||
);
|
||||
assert.equal(f.tool.isActive(), true);
|
||||
claim = false;
|
||||
f.key('Escape');
|
||||
assert.deepEqual(f.cancels, [['escape', undefined]]);
|
||||
f.tool.start();
|
||||
f.key('Enter');
|
||||
assert.equal(asked, 2, 'other keys never reach the interceptor');
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('a held pointer refuses the session and nothing is borrowed', async () => {
|
||||
const f = fixture();
|
||||
const lease = claimPointer('draw');
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.deepEqual(f.cancels, [
|
||||
['pointer-busy', 'draw is using the pointer — close it first'],
|
||||
]);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.handler(), null);
|
||||
releasePointer(lease);
|
||||
assert.equal(f.tool.start(), true);
|
||||
await f.tool.destroy();
|
||||
});
|
||||
|
||||
test('destroy mid-drag restores the camera, releases the claim and detaches the preview', async () => {
|
||||
const f = fixture();
|
||||
f.tool.start();
|
||||
f.fire('LEFT_DOWN', { position: { x: 250, y: 125 } });
|
||||
assert.equal(f.viewer.dataSources.sources.length, 1);
|
||||
await f.tool.destroy();
|
||||
assert.equal(f.camera.enableInputs, true);
|
||||
assert.equal(pointerOwner(), null);
|
||||
assert.equal(f.viewer.dataSources.sources.length, 0);
|
||||
assert.equal(f.stockAction('LEFT_CLICK'), f.originalClick);
|
||||
assert.equal(f.tool.start(), false);
|
||||
assert.equal(initImageryBoxTool({ viewer: null, onBox() {} }), null);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import { onLocaleChange } from '../i18n/index.js';
|
||||
import { useImagerySplitStore } from './stores/imagerySplit.js';
|
||||
import { applicationPinia } from './vueRuntime.js';
|
||||
|
||||
let splitCount = 0;
|
||||
const HANDLE_KEYS = new Set(['ArrowLeft', 'ArrowRight', 'Home', 'End']);
|
||||
|
||||
function clampUnit(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function defaultViewportWidth() {
|
||||
return (
|
||||
Number(document.documentElement?.clientWidth) ||
|
||||
Number(globalThis.window?.innerWidth) ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
// A title or label given as a function is worded when published.
|
||||
const worded = (text) => (typeof text === 'function' ? text() : text);
|
||||
|
||||
function defaultValueText(beforePercent, afterPercent) {
|
||||
return `A ${beforePercent} percent, B ${afterPercent} percent`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split divider for before/after imagery comparison.
|
||||
*
|
||||
* Owns the divider's value, pointer capture, keyboard, ARIA, a CSS custom
|
||||
* property and scene.splitPosition, and publishes the line to the imagery
|
||||
* split store, where the ImagerySplitView renders it as a child of the body.
|
||||
* `setValue` is the owner's own write and never echoes through `onChange`;
|
||||
* `onChange` reports pointer and keyboard changes so the owner can mirror
|
||||
* them, and `requestRender` fires whenever the clamped value actually
|
||||
* changes. `destroy` is idempotent: it withdraws the line, clears the CSS
|
||||
* property and restores the scene split captured at creation once. The
|
||||
* titles and label may be functions, which are called again after a locale
|
||||
* change, like `formatValueText`.
|
||||
*/
|
||||
export function createImagerySplit({
|
||||
scene,
|
||||
initialValue = 0.5,
|
||||
id,
|
||||
handleClass,
|
||||
cssTarget = document.documentElement,
|
||||
cssProperty,
|
||||
beforeLabel = 'A',
|
||||
afterLabel = 'B',
|
||||
beforeTitle,
|
||||
afterTitle,
|
||||
ariaLabel,
|
||||
formatValueText = defaultValueText,
|
||||
getViewportWidth = defaultViewportWidth,
|
||||
onChange,
|
||||
requestRender,
|
||||
store = useImagerySplitStore(applicationPinia()),
|
||||
} = {}) {
|
||||
const key = ++splitCount;
|
||||
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
|
||||
let value = null;
|
||||
let cssValue = null;
|
||||
let hidden = false;
|
||||
let dragging = false;
|
||||
let pointerId = null;
|
||||
// The handle that holds the drag's pointer capture.
|
||||
let captor = null;
|
||||
let destroyed = false;
|
||||
|
||||
function publish() {
|
||||
if (destroyed) return;
|
||||
const beforePercent = Math.round(value * 100);
|
||||
store.publish(
|
||||
key,
|
||||
{
|
||||
id: id ?? null,
|
||||
hidden,
|
||||
handleClass: handleClass ?? null,
|
||||
beforeLabel,
|
||||
afterLabel,
|
||||
beforeTitle: worded(beforeTitle) ?? null,
|
||||
afterTitle: worded(afterTitle) ?? null,
|
||||
ariaLabel: worded(ariaLabel) ?? null,
|
||||
valueNow: beforePercent,
|
||||
valueText: formatValueText(beforePercent, 100 - beforePercent),
|
||||
},
|
||||
controls,
|
||||
);
|
||||
}
|
||||
|
||||
function apply(next, { notify = false, render = true } = {}) {
|
||||
const clamped = clampUnit(next);
|
||||
const changed = clamped !== value;
|
||||
value = clamped;
|
||||
if (scene && scene.splitPosition !== value) scene.splitPosition = value;
|
||||
const css = `${value * 100}%`;
|
||||
if (cssProperty && cssValue !== css) {
|
||||
cssTarget.style.setProperty(cssProperty, css);
|
||||
cssValue = css;
|
||||
}
|
||||
if (changed) publish();
|
||||
if (changed && render) requestRender?.();
|
||||
if (changed && notify) onChange?.(value);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function setFromClientX(clientX) {
|
||||
const viewportWidth = Number(getViewportWidth()) || 0;
|
||||
if (viewportWidth <= 0 || !Number.isFinite(Number(clientX))) return;
|
||||
apply(Number(clientX) / viewportWidth, { notify: true });
|
||||
}
|
||||
|
||||
// The view's handle reports its input here.
|
||||
const controls = {
|
||||
pointerdown(event) {
|
||||
if (destroyed) return;
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
event.preventDefault?.();
|
||||
dragging = true;
|
||||
pointerId = event.pointerId ?? null;
|
||||
captor = event.currentTarget ?? null;
|
||||
captor?.setPointerCapture?.(event.pointerId);
|
||||
setFromClientX(event.clientX);
|
||||
},
|
||||
pointermove(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
event.preventDefault?.();
|
||||
setFromClientX(event.clientX);
|
||||
},
|
||||
pointerup(event) {
|
||||
if (!dragging) return;
|
||||
if (pointerId != null && event.pointerId !== pointerId) return;
|
||||
captor?.releasePointerCapture?.(event.pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
captor = null;
|
||||
},
|
||||
keydown(event) {
|
||||
if (destroyed || !HANDLE_KEYS.has(event.key)) return;
|
||||
event.preventDefault?.();
|
||||
const step = event.shiftKey ? 0.05 : 0.01;
|
||||
if (event.key === 'Home') apply(0, { notify: true });
|
||||
else if (event.key === 'End') apply(1, { notify: true });
|
||||
else
|
||||
apply(value + (event.key === 'ArrowRight' ? step : -step), {
|
||||
notify: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
apply(initialValue, { render: false });
|
||||
const offLocale = onLocaleChange(publish);
|
||||
|
||||
return {
|
||||
getValue() {
|
||||
return value;
|
||||
},
|
||||
setValue(next) {
|
||||
if (destroyed) return false;
|
||||
return apply(next);
|
||||
},
|
||||
setVisible(visible) {
|
||||
if (hidden === !visible) return;
|
||||
hidden = !visible;
|
||||
publish();
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
if (dragging && pointerId != null)
|
||||
captor?.releasePointerCapture?.(pointerId);
|
||||
dragging = false;
|
||||
pointerId = null;
|
||||
captor = null;
|
||||
destroyed = true;
|
||||
offLocale();
|
||||
store.withdraw(key);
|
||||
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
|
||||
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
|
||||
/** The handle the view renders: a pointer capture target. */
|
||||
function fakeHandle() {
|
||||
return {
|
||||
captured: [],
|
||||
released: [],
|
||||
setPointerCapture(id) {
|
||||
this.captured.push(id);
|
||||
},
|
||||
releasePointerCapture(id) {
|
||||
this.released.push(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The imagery split store as the split sees it. */
|
||||
function fakeStore() {
|
||||
const lines = new Map();
|
||||
return {
|
||||
lines,
|
||||
publish(key, view, controls) {
|
||||
lines.set(key, { view, controls });
|
||||
},
|
||||
withdraw(key) {
|
||||
lines.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
|
||||
const cssCalls = [];
|
||||
const changes = [];
|
||||
let renders = 0;
|
||||
const scene = { splitPosition };
|
||||
const cssTarget = {
|
||||
style: {
|
||||
setProperty(name, value) {
|
||||
cssCalls.push(['set', name, value]);
|
||||
},
|
||||
removeProperty(name) {
|
||||
cssCalls.push(['remove', name]);
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = fakeStore();
|
||||
const split = createImagerySplit({
|
||||
scene,
|
||||
initialValue,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
cssTarget,
|
||||
cssProperty: '--split',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
getViewportWidth: () => 1000,
|
||||
onChange: (value) => changes.push(value),
|
||||
requestRender: () => {
|
||||
renders += 1;
|
||||
},
|
||||
store,
|
||||
});
|
||||
const [key] = store.lines.keys();
|
||||
const line = () => store.lines.get(key) ?? null;
|
||||
const handle = fakeHandle();
|
||||
// The view's handle dispatching to the split, as Vue's listeners do.
|
||||
const dispatch = (type, event = {}) =>
|
||||
line()?.controls[type === 'pointercancel' ? 'pointerup' : type]({
|
||||
currentTarget: handle,
|
||||
...event,
|
||||
});
|
||||
return {
|
||||
split,
|
||||
scene,
|
||||
store,
|
||||
line,
|
||||
view: () => line()?.view,
|
||||
handle,
|
||||
dispatch,
|
||||
cssCalls,
|
||||
changes,
|
||||
renders: () => renders,
|
||||
};
|
||||
}
|
||||
|
||||
test('the split publishes its line and applies the initial value without notifying', () => {
|
||||
const env = fixture({ initialValue: 0.62 });
|
||||
assert.deepEqual(env.view(), {
|
||||
id: 'split-line',
|
||||
hidden: false,
|
||||
handleClass: 'split-handle',
|
||||
beforeLabel: 'A',
|
||||
afterLabel: 'B',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
valueNow: 62,
|
||||
valueText: 'A 62 percent, B 38 percent',
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.62);
|
||||
assert.deepEqual(env.cssCalls, [['set', '--split', '62%']]);
|
||||
assert.deepEqual(env.changes, []);
|
||||
assert.equal(env.renders(), 0);
|
||||
assert.equal(env.split.getValue(), 0.62);
|
||||
});
|
||||
|
||||
test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End', () => {
|
||||
const env = fixture();
|
||||
const key = (key, shiftKey = false) => {
|
||||
let prevented = false;
|
||||
env.dispatch('keydown', {
|
||||
key,
|
||||
shiftKey,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
return prevented;
|
||||
};
|
||||
assert.equal(key('ArrowRight'), true);
|
||||
assert.equal(env.scene.splitPosition, 0.51);
|
||||
assert.equal(key('ArrowLeft', true), true);
|
||||
assert.equal(env.scene.splitPosition, 0.46);
|
||||
assert.equal(key('Home'), true);
|
||||
assert.equal(env.scene.splitPosition, 0);
|
||||
assert.equal(env.view().valueNow, 0);
|
||||
assert.equal(key('End'), true);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.view().valueNow, 100);
|
||||
assert.equal(key('Enter'), false, 'other keys are ignored and not prevented');
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.deepEqual(env.changes, [0.51, 0.46, 0, 1]);
|
||||
});
|
||||
|
||||
test('values clamp to the unit range and only real changes notify or render', () => {
|
||||
const env = fixture();
|
||||
assert.equal(env.split.setValue(1.4), true);
|
||||
assert.equal(env.split.getValue(), 1);
|
||||
assert.equal(env.scene.splitPosition, 1);
|
||||
assert.equal(env.split.setValue(1), false);
|
||||
assert.equal(env.split.setValue(-3), true);
|
||||
assert.equal(env.split.getValue(), 0);
|
||||
assert.equal(
|
||||
env.split.setValue('nonsense'),
|
||||
false,
|
||||
'non-numeric input clamps to 0',
|
||||
);
|
||||
assert.equal(env.renders(), 2, 'render only when the clamped value moved');
|
||||
assert.deepEqual(
|
||||
env.changes,
|
||||
[],
|
||||
'setValue is the owner writing; it never echoes',
|
||||
);
|
||||
env.dispatch('keydown', { key: 'Home', preventDefault() {} });
|
||||
env.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
|
||||
assert.deepEqual(
|
||||
env.changes,
|
||||
[],
|
||||
'interaction that cannot move the value stays silent',
|
||||
);
|
||||
env.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
env.dispatch('keydown', { key: 'End', preventDefault() {} });
|
||||
assert.deepEqual(env.changes, [1]);
|
||||
assert.equal(env.renders(), 3);
|
||||
assert.deepEqual(env.cssCalls, [
|
||||
['set', '--split', '50%'],
|
||||
['set', '--split', '100%'],
|
||||
['set', '--split', '0%'],
|
||||
['set', '--split', '100%'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('pointer drags follow the primary pointer only', () => {
|
||||
const env = fixture();
|
||||
env.dispatch('pointerdown', {
|
||||
button: 2,
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.5,
|
||||
'non-primary buttons never start a drag',
|
||||
);
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 1,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.5,
|
||||
'moves without a drag are ignored',
|
||||
);
|
||||
env.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 250,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.25);
|
||||
assert.deepEqual(env.handle.captured, [4]);
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 900,
|
||||
pointerId: 9,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.25,
|
||||
'a different pointer id is ignored',
|
||||
);
|
||||
env.dispatch('pointerup', { pointerId: 9 });
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 750,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.75,
|
||||
'the drag survives a foreign pointerup',
|
||||
);
|
||||
env.dispatch('pointermove', {
|
||||
clientX: Number.NaN,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.75,
|
||||
'unusable coordinates are ignored',
|
||||
);
|
||||
env.dispatch('pointerup', { pointerId: 4 });
|
||||
assert.deepEqual(env.handle.released, [4]);
|
||||
env.dispatch('pointermove', {
|
||||
clientX: 100,
|
||||
pointerId: 4,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.75, 'the drag ended');
|
||||
assert.deepEqual(env.changes, [0.25, 0.75]);
|
||||
});
|
||||
|
||||
test('visibility toggles the line without touching the value', () => {
|
||||
const env = fixture();
|
||||
env.split.setVisible(false);
|
||||
assert.equal(env.view().hidden, true);
|
||||
env.split.setVisible(true);
|
||||
assert.equal(env.view().hidden, false);
|
||||
assert.equal(env.split.getValue(), 0.5);
|
||||
});
|
||||
|
||||
test('destroy withdraws the line, clears the CSS property, restores the scene split once, and is idempotent', () => {
|
||||
const env = fixture({ initialValue: 0.8, splitPosition: 0.37 });
|
||||
env.dispatch('pointerdown', {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
pointerId: 2,
|
||||
preventDefault() {},
|
||||
});
|
||||
assert.equal(env.scene.splitPosition, 0.1);
|
||||
const { controls } = env.line();
|
||||
env.split.destroy();
|
||||
assert.equal(env.line(), null);
|
||||
assert.deepEqual(
|
||||
env.handle.released,
|
||||
[2],
|
||||
'an in-flight drag releases its capture',
|
||||
);
|
||||
assert.equal(env.cssCalls.at(-1)[0], 'remove');
|
||||
assert.equal(env.scene.splitPosition, 0.37);
|
||||
env.scene.splitPosition = 0.9;
|
||||
env.split.destroy();
|
||||
assert.equal(
|
||||
env.scene.splitPosition,
|
||||
0.9,
|
||||
'a second destroy does not restore again',
|
||||
);
|
||||
assert.equal(env.cssCalls.filter(([kind]) => kind === 'remove').length, 1);
|
||||
assert.equal(
|
||||
env.split.setValue(0.2),
|
||||
false,
|
||||
'a destroyed split ignores writes',
|
||||
);
|
||||
assert.equal(env.scene.splitPosition, 0.9);
|
||||
controls.keydown({ key: 'End', preventDefault() {} });
|
||||
controls.pointerdown({ button: 0, clientX: 900, pointerId: 3 });
|
||||
assert.deepEqual(env.changes, [0.1], 'its controls are inert');
|
||||
assert.equal(env.store.lines.size, 0, 'nothing republishes the line');
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// src/ui/imagerySplitView.test.mjs — the imagery dividers render from the
|
||||
// imagery split store as children of the body, over happy-dom.
|
||||
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 { useImagerySplitStore } from './stores/imagerySplit.js';
|
||||
import { createImagerySplit } from './imagerySplit.js';
|
||||
import ImagerySplitView from './views/panels/ImagerySplitView.vue';
|
||||
|
||||
function fixture(t) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(ImagerySplitView).use(pinia);
|
||||
app.mount(host);
|
||||
t.after(() => app.unmount());
|
||||
const store = useImagerySplitStore(pinia);
|
||||
const scene = { splitPosition: 0.37 };
|
||||
const changes = [];
|
||||
const split = (options = {}) =>
|
||||
createImagerySplit({
|
||||
scene,
|
||||
id: 'split-line',
|
||||
handleClass: 'split-handle',
|
||||
cssProperty: '--split',
|
||||
beforeTitle: 'Before',
|
||||
afterTitle: 'After',
|
||||
ariaLabel: 'Divider',
|
||||
getViewportWidth: () => 1000,
|
||||
onChange: (value) => changes.push(value),
|
||||
store,
|
||||
...options,
|
||||
});
|
||||
return { split, scene, changes };
|
||||
}
|
||||
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
|
||||
test('the divider renders on the body with its labels and slider', async (t) => {
|
||||
const { split } = fixture(t);
|
||||
split({ initialValue: 0.62 });
|
||||
await nextTick();
|
||||
const line = $('#split-line');
|
||||
assert.equal(line.parentElement, document.body);
|
||||
const [before, handle, after] = line.children;
|
||||
assert.equal(before.className, 'before');
|
||||
assert.equal(before.textContent, 'A');
|
||||
assert.equal(before.title, 'Before');
|
||||
assert.equal(after.className, 'after');
|
||||
assert.equal(after.textContent, 'B');
|
||||
assert.equal(after.title, 'After');
|
||||
assert.equal(handle.tagName, 'BUTTON');
|
||||
assert.equal(handle.type, 'button');
|
||||
assert.equal(handle.className, 'split-handle');
|
||||
assert.equal(handle.textContent, '↔');
|
||||
assert.equal(handle.getAttribute('role'), 'slider');
|
||||
assert.equal(handle.getAttribute('aria-label'), 'Divider');
|
||||
assert.equal(handle.getAttribute('aria-valuemin'), '0');
|
||||
assert.equal(handle.getAttribute('aria-valuemax'), '100');
|
||||
assert.equal(handle.getAttribute('aria-orientation'), 'horizontal');
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '62');
|
||||
assert.equal(
|
||||
handle.getAttribute('aria-valuetext'),
|
||||
'A 62 percent, B 38 percent',
|
||||
);
|
||||
assert.equal(
|
||||
document.documentElement.style.getPropertyValue('--split'),
|
||||
'62%',
|
||||
);
|
||||
});
|
||||
|
||||
test('the handle steps by keyboard and drags with the pointer it captured', async (t) => {
|
||||
const { split, scene, changes } = fixture(t);
|
||||
split();
|
||||
await nextTick();
|
||||
const handle = $('#split-line button');
|
||||
const captured = [];
|
||||
const released = [];
|
||||
handle.setPointerCapture = (id) => captured.push(id);
|
||||
handle.releasePointerCapture = (id) => released.push(id);
|
||||
handle.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||
);
|
||||
await nextTick();
|
||||
assert.equal(scene.splitPosition, 0.51);
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '51');
|
||||
const pointer = (type, init) =>
|
||||
handle.dispatchEvent(
|
||||
new PointerEvent(type, { bubbles: true, pointerId: 7, ...init }),
|
||||
);
|
||||
pointer('pointerdown', { button: 0, clientX: 250 });
|
||||
pointer('pointermove', { clientX: 800 });
|
||||
pointer('pointercancel', {});
|
||||
pointer('pointermove', { clientX: 100 });
|
||||
await nextTick();
|
||||
assert.equal(scene.splitPosition, 0.8);
|
||||
assert.deepEqual(captured, [7]);
|
||||
assert.deepEqual(released, [7]);
|
||||
assert.deepEqual(changes, [0.51, 0.25, 0.8]);
|
||||
assert.equal(handle.getAttribute('aria-valuenow'), '80');
|
||||
});
|
||||
|
||||
test('hiding and destroying a divider leave other dividers in place', async (t) => {
|
||||
const { split } = fixture(t);
|
||||
const first = split();
|
||||
const second = split({ id: 'second-line' });
|
||||
await nextTick();
|
||||
first.setVisible(false);
|
||||
await nextTick();
|
||||
assert.equal($('#split-line').hidden, true);
|
||||
assert.equal($('#second-line').hidden, false);
|
||||
first.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#split-line'), null);
|
||||
assert.ok($('#second-line'));
|
||||
second.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#second-line'), null);
|
||||
});
|
||||
|
||||
test('a divider created before the view mounts renders once it does', async (t) => {
|
||||
document.body.replaceChildren();
|
||||
const pinia = createPinia();
|
||||
const split = createImagerySplit({
|
||||
id: 'early-line',
|
||||
getViewportWidth: () => 1000,
|
||||
store: useImagerySplitStore(pinia),
|
||||
});
|
||||
const app = createApp(ImagerySplitView).use(pinia);
|
||||
app.mount(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => app.unmount());
|
||||
await nextTick();
|
||||
assert.equal($('#early-line').parentElement, document.body);
|
||||
split.destroy();
|
||||
await nextTick();
|
||||
assert.equal($('#early-line'), null);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Key guidance in the UI language. keySetupCore.mjs builds the same sentences
|
||||
* in English for the server and the AI tools; these are the ones people read.
|
||||
*/
|
||||
import {
|
||||
KEY_SETUP_KEYS,
|
||||
keySetupRequirementEnvVars,
|
||||
} from '../keySetupCore.mjs';
|
||||
import { t, translateKnown } from '../i18n/index.js';
|
||||
|
||||
/** "Needs X — add it in Provider Settings", or '' for an unknown or hidden key. */
|
||||
export function keyRequirementText(id) {
|
||||
const envVars = id ? keySetupRequirementEnvVars(id) : null;
|
||||
return envVars
|
||||
? t('Needs {envVars} — add it in Provider Settings', { envVars })
|
||||
: '';
|
||||
}
|
||||
|
||||
/** The key's product name in title case: GOOGLE MAPS → Google Maps. */
|
||||
function keyName(id) {
|
||||
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
|
||||
return entry
|
||||
? entry.title
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The requirement, then the switched-off sources that would draw with a key
|
||||
* already set (see keySetupWithAlternatives).
|
||||
*
|
||||
* @param {string} requirement
|
||||
* @param {Array<{label: string, requiresKeyId: string|null}>} [alternatives]
|
||||
*/
|
||||
export function keyGuidanceText(requirement, alternatives = []) {
|
||||
const options = alternatives.map(({ label, requiresKeyId }) => {
|
||||
const name = keyName(requiresKeyId);
|
||||
const source = translateKnown(label);
|
||||
return name
|
||||
? t('light {source} to use your {name} key', { source, name })
|
||||
: t('light {source}', { source });
|
||||
});
|
||||
if (!options.length) return requirement;
|
||||
const offer = options.join(t(', or '));
|
||||
if (requirement)
|
||||
return t('{requirement}, or {offer}', { requirement, offer });
|
||||
return offer.charAt(0).toLocaleUpperCase() + offer.slice(1);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiBadge;
|
||||
let UiEmpty;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiBadge = (await import('./UiBadge.vue')).default;
|
||||
UiEmpty = (await import('./UiEmpty.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
test('a badge carries the kit classes, Core’s and the caller’s', async () => {
|
||||
const wrapper = mount(UiBadge, {
|
||||
attrs: { id: 'cal', class: 'gev-status-badge--box', role: 'status' },
|
||||
slots: { default: 'CAL · --' },
|
||||
});
|
||||
const badge = wrapper.get('span');
|
||||
assert.deepEqual(badge.classes(), [
|
||||
'ui-badge',
|
||||
'gev-status-badge',
|
||||
'ui-badge--neutral',
|
||||
'gev-status-badge--box',
|
||||
]);
|
||||
assert.equal(badge.attributes('role'), 'status');
|
||||
assert.equal(badge.element.textContent, 'CAL · --');
|
||||
await wrapper.setProps({ tone: 'warn' });
|
||||
assert.ok(badge.classes().includes('ui-badge--warn'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('an empty state is its title, then its hint', async () => {
|
||||
const wrapper = mount(UiEmpty, {
|
||||
props: { title: 'No shots yet.' },
|
||||
attrs: { class: 'scene-shot-empty', hidden: true },
|
||||
});
|
||||
const empty = wrapper.get('div');
|
||||
assert.deepEqual(empty.classes(), ['ui-empty', 'scene-shot-empty']);
|
||||
assert.equal(empty.element.textContent, 'No shots yet.');
|
||||
assert.equal(empty.attributes('title'), undefined);
|
||||
assert.equal(empty.element.hidden, true);
|
||||
await wrapper.setProps({ hint: 'Capture one.' });
|
||||
assert.equal(empty.get('.ui-empty__hint').text(), 'Capture one.');
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
// Core's read-only badge, with a `tone` and the kit's classes. It carries Core's `gev-status-badge` class, and
|
||||
// callers add its density variants (`gev-status-badge--box`, `--plain`) and
|
||||
// the feature classes that colour it; the kit classes carry no Core styles
|
||||
// yet. Not mirrored yet: pulse, busy, the tone glyphs and the clipped title.
|
||||
defineProps({
|
||||
tone: { type: String, default: 'neutral' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ui-badge gev-status-badge" :class="`ui-badge--${tone}`"
|
||||
><slot
|
||||
/></span>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let vue;
|
||||
let renderToString;
|
||||
let UiButton;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
vue = await import('vue');
|
||||
({ renderToString } = await import('vue/server-renderer'));
|
||||
UiButton = (await import('./UiButton.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
function mountButton(props = {}, attrs = {}) {
|
||||
return mount(UiButton, {
|
||||
attachTo: document.body,
|
||||
props,
|
||||
attrs,
|
||||
slots: { default: 'Go' },
|
||||
});
|
||||
}
|
||||
|
||||
test('a pressed toggle carries aria-pressed, the kit class and Core’s', async () => {
|
||||
const wrapper = mountButton(
|
||||
{ pressed: false, pressedClass: 'active' },
|
||||
{ id: 'go', class: 'pp-toggle-btn' },
|
||||
);
|
||||
const button = wrapper.get('button');
|
||||
assert.equal(button.attributes('aria-pressed'), 'false');
|
||||
assert.equal(button.classes().includes('active'), false);
|
||||
await wrapper.setProps({ pressed: true });
|
||||
assert.equal(button.attributes('aria-pressed'), 'true');
|
||||
assert.ok(button.classes().includes('active'));
|
||||
assert.ok(button.classes().includes('ui-button--pressed'));
|
||||
assert.ok(button.classes().includes('pp-toggle-btn'));
|
||||
assert.equal(button.attributes('type'), 'button');
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('no aria-pressed unless pressed is passed', () => {
|
||||
const wrapper = mountButton();
|
||||
assert.equal(wrapper.get('button').attributes('aria-pressed'), undefined);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a class other code adds survives a press', async () => {
|
||||
const wrapper = mountButton({ pressed: false, pressedClass: 'active' });
|
||||
const button = wrapper.get('button').element;
|
||||
button.classList.add('voice-highlight');
|
||||
await wrapper.setProps({ pressed: true });
|
||||
await wrapper.setProps({ pressed: false });
|
||||
assert.ok(button.classList.contains('voice-highlight'));
|
||||
assert.equal(button.classList.contains('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a caller’s class change keeps the pressed classes', async () => {
|
||||
const wrapper = mountButton(
|
||||
{ pressed: true, pressedClass: 'active' },
|
||||
{ class: { panoptic: false } },
|
||||
);
|
||||
await wrapper.setProps({ class: { panoptic: true } });
|
||||
const classes = wrapper.get('button').classes();
|
||||
assert.ok(classes.includes('panoptic'));
|
||||
assert.ok(classes.includes('active'));
|
||||
assert.ok(classes.includes('ui-button--pressed'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('a renamed pressedClass leaves the button', async () => {
|
||||
const wrapper = mountButton({ pressed: true, pressedClass: 'active' });
|
||||
await wrapper.setProps({ pressedClass: 'selected' });
|
||||
let classes = wrapper.get('button').classes();
|
||||
assert.equal(classes.includes('active'), false);
|
||||
assert.ok(classes.includes('selected'));
|
||||
await wrapper.setProps({ pressed: false });
|
||||
classes = wrapper.get('button').classes();
|
||||
assert.equal(classes.includes('selected'), false);
|
||||
assert.equal(classes.includes('active'), false);
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
test('server-rendered markup carries the pressed classes', async () => {
|
||||
const { createSSRApp, h } = vue;
|
||||
const html = await renderToString(
|
||||
createSSRApp({
|
||||
render: () =>
|
||||
h(
|
||||
UiButton,
|
||||
{ id: 'fm', pressed: true, pressedClass: 'active' },
|
||||
() => 'FM',
|
||||
),
|
||||
}),
|
||||
);
|
||||
assert.match(html, /<button[^>]*class="[^"]*\bactive\b[^"]*"/);
|
||||
assert.match(html, /aria-pressed="true"/);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
|
||||
// Core's button and its attribute contract: the button is always the root,
|
||||
// attributes passed in win over its own (a `title` replaces the label's), and
|
||||
// `pressed` renders aria-pressed only when it is passed. Core styles the
|
||||
// button through the classes its callers pass; the kit classes carry no Core
|
||||
// styles yet. Core adds `pressedClass`, its class on a pressed button (most
|
||||
// toggles use `active`). The pressed classes toggle through `classList`, so
|
||||
// classes other code adds to the button (a voice highlight) survive a press.
|
||||
// Not mirrored yet: icon, loading, disabledReason and the missing-label
|
||||
// warning.
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'ghost' },
|
||||
tone: { type: String, default: 'accent' },
|
||||
/** The accessible name (aria-label) and, with nativeTitle, the tip. */
|
||||
label: { type: String, default: undefined },
|
||||
pressed: { type: Boolean, default: undefined },
|
||||
/** Core's class on the button while it is pressed. */
|
||||
pressedClass: { type: String, default: undefined },
|
||||
disabled: { type: Boolean, default: false },
|
||||
type: { type: String, default: 'button' },
|
||||
/** The id of the form a submit button belongs to, where it sits outside it. */
|
||||
form: { type: String, default: undefined },
|
||||
nativeTitle: { type: Boolean, default: true },
|
||||
});
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
// A pressedClass that changes keeps its old name, switched off, so the class
|
||||
// leaves the button with it.
|
||||
const pressedClasses = computed((previous) => {
|
||||
const on = Boolean(props.pressed);
|
||||
const classes = { 'ui-button--pressed': on };
|
||||
for (const name of Object.keys(previous || {}))
|
||||
if (name !== 'ui-button--pressed') classes[name] = false;
|
||||
if (props.pressedClass) classes[props.pressedClass] = on;
|
||||
return classes;
|
||||
});
|
||||
// Server-rendered markup carries the pressed classes in its class attribute.
|
||||
const vPressedClasses = {
|
||||
...vClassToggle,
|
||||
getSSRProps: ({ value }) => ({
|
||||
class: Object.keys(value).filter((name) => value[name]),
|
||||
}),
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="props.type"
|
||||
:form="props.form"
|
||||
class="ui-button ui-focus"
|
||||
:class="[`ui-button--${props.variant}`, `ui-button--${props.tone}`]"
|
||||
:aria-label="props.label"
|
||||
:title="props.nativeTitle ? props.label : undefined"
|
||||
:aria-pressed="
|
||||
props.pressed === undefined ? undefined : props.pressed ? 'true' : 'false'
|
||||
"
|
||||
:disabled="props.disabled"
|
||||
v-bind="$attrs"
|
||||
v-pressed-classes="pressedClasses"
|
||||
@click="emit('click', $event)"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiChip;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiChip = (await import('./UiChip.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
test('a chip is a span with the kit classes and Core’s', async () => {
|
||||
const wrapper = mount(UiChip, {
|
||||
attrs: { class: 'gev-voice-card-chip', 'data-tone': 'ok' },
|
||||
slots: { default: 'ok' },
|
||||
});
|
||||
const chip = wrapper.get('span');
|
||||
assert.deepEqual(chip.classes(), ['ui-chip', 'gev-voice-card-chip']);
|
||||
assert.equal(chip.attributes('data-tone'), 'ok');
|
||||
assert.equal(chip.text(), 'ok');
|
||||
await wrapper.setProps({ selected: true, mono: true });
|
||||
assert.ok(chip.classes().includes('ui-chip--selected'));
|
||||
assert.ok(chip.classes().includes('ui-mono'));
|
||||
wrapper.unmount();
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
// Core's chip: a label that says what something is, never a control.
|
||||
// Core styles it through the classes its callers pass; the kit classes carry
|
||||
// no Core styles yet. A chip that presses is a UiButton with pressedClass.
|
||||
defineProps({
|
||||
selected: { type: Boolean, default: false },
|
||||
mono: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ui-chip"
|
||||
:class="{ 'ui-chip--selected': selected, 'ui-mono': mono }"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
// Core's empty state, with a `title` and `hint`. Core's empty states are one
|
||||
// line, so the title is the text itself rather than a bold heading; a hint
|
||||
// follows it. Core
|
||||
// styles it through the classes its callers pass. Not mirrored yet: icon.
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
hint: { type: String, default: undefined },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ui-empty">
|
||||
{{ title }}<span v-if="hint" class="ui-empty__hint">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
/** A Material Symbols glyph; the name must be in index.html's `icon_names`. */
|
||||
defineProps({
|
||||
name: { type: String, required: true },
|
||||
size: { type: Number, default: 18 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ui-icon material-symbols-outlined"
|
||||
:style="{ fontSize: `${size}px` }"
|
||||
aria-hidden="true"
|
||||
>{{ name }}</span
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { vClassToggle } from '../rail/classToggle.js';
|
||||
import UiPanelToggle from './UiPanelToggle.vue';
|
||||
|
||||
// Core's panel, with a name, title, slots and v-model:collapsed. It draws Core's panel markup
|
||||
// (glow, inner, a header with the title, badges, divider, actions and the
|
||||
// collapse button, then the body) and keeps Core's classes beside the kit's.
|
||||
// `collapsed` does not hide the body: the root's classes
|
||||
// stay as rendered, and whatever owns the panel's state writes its
|
||||
// `collapsed` class at once (PanelChrome, through vPanelState), because the
|
||||
// rails measure a panel in the task that opens it. `collapsed` drives the
|
||||
// button and the kit's `ui-panel--collapsed`, toggled without a re-render.
|
||||
// There is no Close: Core's panels collapse.
|
||||
const props = defineProps({
|
||||
/** The root's id, its inner's class (`{id}-inner`) and the button's target. */
|
||||
id: { type: String, required: true },
|
||||
/** The heading, already translated; also the button's "Expand {name}". */
|
||||
title: { type: String, required: true },
|
||||
/** The heading's tip. */
|
||||
hint: { type: String, default: undefined },
|
||||
tag: { type: String, default: 'div' },
|
||||
collapsible: { type: Boolean, default: false },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
|
||||
sideways: { type: Boolean, default: false },
|
||||
/** Words that replace "Expand/Collapse {title}" on the button. */
|
||||
toggleWords: { type: Object, default: null },
|
||||
glow: { type: Boolean, default: true },
|
||||
/** The inner scrolls with Core's scroll bar. */
|
||||
scroll: { type: Boolean, default: false },
|
||||
/** The inner is a scroller the right rail keeps in view. */
|
||||
railScroller: { type: Boolean, default: false },
|
||||
});
|
||||
const emit = defineEmits(['update:collapsed']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.tag"
|
||||
:id="props.id"
|
||||
v-class-toggle="{
|
||||
'ui-panel--collapsed': props.collapsible && props.collapsed,
|
||||
}"
|
||||
class="ui-panel panel-collapsible"
|
||||
>
|
||||
<div v-if="props.glow" class="panel-glow"></div>
|
||||
<div
|
||||
:class="[`${props.id}-inner`, { 'ui-scroll': props.scroll }]"
|
||||
:data-rail-scroller="props.railScroller ? '' : undefined"
|
||||
>
|
||||
<div class="ui-panel__head panel-header">
|
||||
<slot name="title">
|
||||
<span class="ui-panel__title panel-title" :title="props.hint">{{
|
||||
props.title
|
||||
}}</span>
|
||||
</slot>
|
||||
<slot name="badge" />
|
||||
<span class="panel-divider"></span>
|
||||
<slot name="actions" />
|
||||
<UiPanelToggle
|
||||
v-if="props.collapsible"
|
||||
:target="props.id"
|
||||
:name="props.title"
|
||||
:collapsed="props.collapsed"
|
||||
:sideways="props.sideways"
|
||||
:words="props.toggleWords"
|
||||
@update:collapsed="emit('update:collapsed', $event)"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import UiButton from './UiButton.vue';
|
||||
|
||||
// A panel's collapse button, internal to the kit: UiPanel draws it, and so
|
||||
// does PanelCollapseButton for the panels not built on UiPanel. Its glyph and
|
||||
// words follow `collapsed`; a click asks for the other state.
|
||||
const props = defineProps({
|
||||
/** The panel's id, which PanelChrome finds the button by. */
|
||||
target: { type: String, required: true },
|
||||
/** The panel's name in "Expand {name}", already translated; "panel" when unset. */
|
||||
name: { type: String, default: null },
|
||||
collapsed: { type: Boolean, required: true },
|
||||
/** A right-rail panel opens and closes sideways: ◀ / ▶, not + / −. */
|
||||
sideways: { type: Boolean, default: false },
|
||||
/** Words that replace "Expand/Collapse {name}" (Radio's own wording). */
|
||||
words: { type: Object, default: null },
|
||||
});
|
||||
const emit = defineEmits(['update:collapsed']);
|
||||
|
||||
const glyph = computed(() => {
|
||||
if (props.sideways) return props.collapsed ? '◀' : '▶';
|
||||
return props.collapsed ? '+' : '−';
|
||||
});
|
||||
const label = computed(() => {
|
||||
if (props.words)
|
||||
return props.collapsed
|
||||
? props.words.expandLabel
|
||||
: props.words.collapseLabel;
|
||||
const key = props.collapsed ? msg('Expand {name}') : msg('Collapse {name}');
|
||||
return t(key, { name: props.name ?? t('panel') });
|
||||
});
|
||||
const title = computed(() => {
|
||||
if (props.words)
|
||||
return props.collapsed
|
||||
? props.words.expandTitle
|
||||
: props.words.collapseTitle;
|
||||
return label.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UiButton
|
||||
variant="icon"
|
||||
class="panel-collapse-btn"
|
||||
:data-collapse-target="target"
|
||||
:label="label"
|
||||
:title="title"
|
||||
:aria-expanded="String(!collapsed)"
|
||||
@click="emit('update:collapsed', !collapsed)"
|
||||
>{{ glyph }}</UiButton
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,145 @@
|
||||
import { after, before, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installVueDom } from '../../testSupport/vueDom.mjs';
|
||||
|
||||
let mount;
|
||||
let UiRail;
|
||||
let uninstall;
|
||||
before(async () => {
|
||||
uninstall = installVueDom();
|
||||
({ mount } = await import('@vue/test-utils'));
|
||||
UiRail = (await import('./UiRail.vue')).default;
|
||||
});
|
||||
after(() => uninstall());
|
||||
|
||||
const items = [
|
||||
{ id: 'layers', label: 'DATA LAYERS', icon: 'layers' },
|
||||
{ id: 'scenes', label: 'AUTHORING', icon: 'movie', separatorBefore: true },
|
||||
{ id: 'cctv', label: 'CCTV', icon: 'videocam', disabled: true, badge: 3 },
|
||||
];
|
||||
|
||||
function mountRail(props = {}) {
|
||||
return mount(UiRail, {
|
||||
props: { items, side: 'left', label: 'Left panels', ...props },
|
||||
});
|
||||
}
|
||||
|
||||
test('the rail is a named nav with one pressed button per item', () => {
|
||||
const wrapper = mountRail({ active: 'scenes' });
|
||||
const nav = wrapper.get('nav');
|
||||
assert.equal(nav.attributes('aria-label'), 'Left panels');
|
||||
assert.ok(nav.classes().includes('ui-rail--left'));
|
||||
const buttons = wrapper.findAll('button');
|
||||
assert.deepEqual(
|
||||
buttons.map((button) => button.attributes('data-rail-item')),
|
||||
['layers', 'scenes', 'cctv'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
buttons.map((button) => button.attributes('aria-pressed')),
|
||||
['false', 'true', 'false'],
|
||||
);
|
||||
assert.equal(buttons[0].attributes('aria-label'), 'DATA LAYERS');
|
||||
assert.equal(buttons[0].get('.material-symbols-outlined').text(), 'layers');
|
||||
assert.ok(buttons[1].classes().includes('ui-rail__item--on'));
|
||||
assert.equal(wrapper.findAll('.ui-rail__sep').length, 1);
|
||||
assert.equal(buttons[2].attributes('disabled'), '');
|
||||
assert.equal(buttons[2].get('.ui-rail__badge').text(), '3');
|
||||
});
|
||||
|
||||
test('selecting an item emits its id', async () => {
|
||||
const wrapper = mountRail();
|
||||
await wrapper.get('[data-rail-item="scenes"]').trigger('click');
|
||||
assert.deepEqual(wrapper.emitted('select'), [['scenes']]);
|
||||
});
|
||||
|
||||
test('tips open toward the screen, and not for the open panel', async () => {
|
||||
const wrapper = mountRail({
|
||||
side: 'right',
|
||||
active: 'layers',
|
||||
panelOpen: true,
|
||||
});
|
||||
const [open, other] = wrapper.findAll('.ui-tooltip');
|
||||
await open.trigger('focusin');
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
await other.trigger('focusin');
|
||||
// Over an open panel the other tips wait for the pointer to rest.
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
const tip = wrapper.get('[role="tooltip"]');
|
||||
assert.equal(tip.text(), 'AUTHORING');
|
||||
assert.ok(tip.classes().includes('ui-tooltip__tip--left'));
|
||||
await other.trigger('focusout');
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
});
|
||||
|
||||
test('a collapsible rail shows labelled pills until a panel opens', async () => {
|
||||
const wrapper = mountRail({ collapsible: true });
|
||||
const nav = wrapper.get('nav');
|
||||
assert.ok(nav.classes().includes('ui-rail--collapsible'));
|
||||
assert.ok(nav.classes().includes('ui-rail--expanded'));
|
||||
assert.deepEqual(
|
||||
wrapper.findAll('.ui-rail__label').map((label) => label.text()),
|
||||
['DATA LAYERS', 'AUTHORING', 'CCTV'],
|
||||
);
|
||||
// The label is on screen, so its tip would only repeat it.
|
||||
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
// The button keeps its own accessible name; the visible label is hidden
|
||||
// from assistive technology so the name is not read twice.
|
||||
const button = wrapper.get('[data-rail-item="layers"]');
|
||||
assert.equal(button.attributes('aria-label'), 'DATA LAYERS');
|
||||
assert.equal(button.get('.ui-rail__label').attributes('aria-hidden'), 'true');
|
||||
|
||||
await wrapper.setProps({ active: 'layers', panelOpen: true });
|
||||
assert.ok(nav.classes().includes('ui-rail--collapsible'));
|
||||
assert.equal(nav.classes().includes('ui-rail--expanded'), false);
|
||||
// Collapsed, the other items' tips come back.
|
||||
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
|
||||
await new Promise((resolve) => setTimeout(resolve, 450));
|
||||
assert.equal(wrapper.get('[role="tooltip"]').text(), 'AUTHORING');
|
||||
});
|
||||
|
||||
test('a label cut short by the pill keeps its tip', async (t) => {
|
||||
const observers = [];
|
||||
const original = globalThis.ResizeObserver;
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.targets = [];
|
||||
observers.push(this);
|
||||
}
|
||||
observe(target) {
|
||||
this.targets.push(target);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
t.after(() => (globalThis.ResizeObserver = original));
|
||||
const wrapper = mountRail({ collapsible: true });
|
||||
const [observer] = observers;
|
||||
const [layers, scenes] = observer.targets;
|
||||
Object.defineProperties(layers, {
|
||||
scrollWidth: { value: 260 },
|
||||
clientWidth: { value: 220 },
|
||||
});
|
||||
Object.defineProperties(scenes, {
|
||||
scrollWidth: { value: 90 },
|
||||
clientWidth: { value: 90 },
|
||||
});
|
||||
observer.callback([{ target: layers }, { target: scenes }]);
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.findAll('.ui-tooltip')[0].trigger('focusin');
|
||||
assert.equal(wrapper.get('[role="tooltip"]').text(), 'DATA LAYERS');
|
||||
await wrapper.findAll('.ui-tooltip')[0].trigger('focusout');
|
||||
await wrapper.findAll('.ui-tooltip')[1].trigger('focusin');
|
||||
assert.equal(wrapper.find('[role="tooltip"]').exists(), false);
|
||||
});
|
||||
|
||||
test('a plain rail has no labels', () => {
|
||||
const wrapper = mountRail();
|
||||
assert.equal(wrapper.find('.ui-rail__label').exists(), false);
|
||||
assert.equal(
|
||||
wrapper.get('nav').classes().includes('ui-rail--expanded'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
Loaded 100 of 2330 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user