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:
Said Turksever committed 2026-11-02 15:55:40 +00:00
commit ce1ad3b95a
2330 files changed
+632956 -84310

No files matched your search

+48
View File
@@ -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>
+91
View File
@@ -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
+47
View File
@@ -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);
},
};
}
+247
View File
@@ -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>&lt;Area two&gt;',
);
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',
);
});
+361
View File
@@ -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();
},
};
}
+673
View File
@@ -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);
});
+1
View File
@@ -0,0 +1 @@
export { CctvControls } from './cctvControls.js';
+65
View File
@@ -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>
+33
View File
@@ -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>
+83
View File
@@ -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',
});
});
+163
View File
@@ -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();
}
+189
View File
@@ -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,
};
}
+163
View File
@@ -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 });
}
}
+127
View File
@@ -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);
});
+184
View File
@@ -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),
);
}
+515
View File
@@ -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° ·/);
});
+228
View File
@@ -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');
});
+378
View File
@@ -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 });
}
}
+59
View File
@@ -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);
},
};
}
+40
View File
@@ -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);
});
+33
View File
@@ -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);
},
};
}
+2
View File
@@ -0,0 +1,2 @@
export { CockpitViewController } from './cockpitController.js';
export { CockpitDisplayPortal } from './cockpitDisplayPortal.js';
+76
View File
@@ -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: '—',
},
};
}
+294
View File
@@ -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();
}
+289
View File
@@ -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');
});
+360
View File
@@ -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);
}
}
+191
View File
@@ -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();
}
}
+280
View File
@@ -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',
);
});
+38
View File
@@ -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,
};
}
+451
View File
@@ -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 });
}
}
+246
View File
@@ -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();
}
});
+415
View File
@@ -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;
}
}
+155
View File
@@ -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;
}
}
+259
View File
@@ -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();
}
});
+63
View File
@@ -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;
}
}
+95
View File
@@ -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]),
};
}
+257
View File
@@ -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();
}
+423
View File
@@ -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'),
/^Текущий/,
);
});
+312
View File
@@ -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 };
}
+257
View File
@@ -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']);
});
+146
View File
@@ -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}°`;
}
+18
View File
@@ -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$/);
});
+116
View File
@@ -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;
}
+111
View File
@@ -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');
});
+156
View File
@@ -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' },
);
}
+266
View File
@@ -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;
}
+452
View File
@@ -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);
});
+488
View File
@@ -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);
});
+126
View File
@@ -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,
},
});
}
}
+1
View File
@@ -0,0 +1 @@
export { ContextControls } from './contextControls.js';
+270
View File
@@ -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;
}
+125
View File
@@ -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();
},
);
});
}
+150
View File
@@ -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);
}
}
+155
View File
@@ -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);
});
+116
View File
@@ -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',
);
});
}
+260
View File
@@ -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();
}
+173
View File
@@ -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');
});
+29
View File
@@ -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,
});
}
+183
View File
@@ -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 });
}
+164
View File
@@ -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();
}
+272
View File
@@ -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;
}
}
+34
View File
@@ -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"`);
}
});
+98
View File
@@ -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 }),
};
}
+179
View File
@@ -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);
});
+913
View File
@@ -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*\}/,
);
});
+182
View File
@@ -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;
}
}
+10
View File
@@ -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;
}
+689
View File
@@ -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]);
});
+232
View File
@@ -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'],
]);
});
+6
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
export { VisualEffects } from './visualEffects.js';
export {
STYLES,
GLOBAL_POST_DEFAULTS,
STYLE_PRESET_DEFAULTS,
MILITARY_DETECTION_PRESET,
} from './visualPresets.js';
+69
View File
@@ -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');
}
});
}
+122
View File
@@ -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;
}
+163
View File
@@ -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);
});
+99
View File
@@ -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);
},
};
}
+143
View File
@@ -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);
});
+44
View File
@@ -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);
});
+453
View File
@@ -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(' ');
}
+626
View File
@@ -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();
});
+175
View File
@@ -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',
);
});
+59
View File
@@ -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;
}
+96
View File
@@ -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;
});
+295
View File
@@ -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;
},
};
}
+154
View File
@@ -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);
});
+182
View File
@@ -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;
},
};
}
+295
View File
@@ -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');
});
+139
View File
@@ -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);
});
+49
View File
@@ -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);
}
+49
View File
@@ -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();
});
+15
View File
@@ -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>
+102
View File
@@ -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"/);
});
+69
View File
@@ -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>
+28
View File
@@ -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();
});
+18
View File
@@ -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>
+16
View File
@@ -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>
+16
View File
@@ -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>
+73
View File
@@ -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>
+54
View File
@@ -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>
+145
View File
@@ -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