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

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

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

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

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

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
<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"
@select="onSelect"
/>
</template>
-41
View File
@@ -1,41 +0,0 @@
/**
* Create an ordinary native action button. Static templates use the same CSS
* classes. Labels are already translated; feature owners retain callbacks,
* disabled-state updates and listener cleanup. Construction binds no listeners.
* @param {object} options
* @param {Document} [options.documentRef] Document that owns the button.
* @param {string} options.label Visible text, never interpreted as markup.
* @param {string} [options.className] Additional feature/layout hooks.
* @param {'default'|'danger'} [options.variant]
* @param {'default'|'compact'} [options.size]
* @param {boolean} [options.disabled]
* @param {string} [options.ariaLabel] Accessible name when visible text needs context.
* @param {string} [options.title]
* @returns {HTMLButtonElement}
*/
export function createActionButton({
documentRef = globalThis.document,
label,
className = '',
variant = 'default',
size = 'default',
disabled = false,
ariaLabel,
title,
}) {
const button = documentRef.createElement('button');
button.type = 'button';
button.className = [
'gev-action-button',
variant === 'danger' && 'gev-action-button--danger',
size === 'compact' && 'gev-action-button--compact',
className,
]
.filter(Boolean)
.join(' ');
button.textContent = label;
button.disabled = disabled;
if (ariaLabel !== undefined) button.setAttribute('aria-label', ariaLabel);
if (title !== undefined) button.title = title;
return button;
}
+37 -24
View File
@@ -1,19 +1,28 @@
import { syncSegmentedControl } from './segmentedControl.js';
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 }) {
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';
this._models3dModeBtns = [
document.getElementById('models3d-mode-proximity'),
document.getElementById('models3d-mode-all'),
];
// 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();
@@ -28,20 +37,28 @@ export class AircraftDisplay {
if (!options) return;
this._models3dEnabled = options.models3d === true;
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
this._syncModels3dButtonState();
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
valueOf: (button) => button.dataset.mode,
});
this._models3dPressedShown = this._models3dEnabled;
this._models3dRowShown = this._models3dEnabled;
this._models3dModeShown = this._models3dMode;
this._publishModels3d();
this._layoutRightPanels();
}
_syncModels3dModeRow() {
if (this._models3dModeRow)
this._models3dModeRow.classList.toggle('visible', this._models3dEnabled);
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();
@@ -58,21 +75,17 @@ export class AircraftDisplay {
const normalized = mode === 'all' ? 'all' : 'proximity';
this._models3dMode = normalized;
this._setModels3dParams({ models3dMode: normalized });
syncSegmentedControl(this._models3dModeBtns, normalized, {
valueOf: (button) => button.dataset.mode,
});
this._models3dModeShown = normalized;
this._syncModels3dButtonState();
}
_syncModels3dButtonState() {
this._models3dBtn?.classList.toggle('active', this._models3dEnabled);
// The lit/dark state is a colour to a sighted operator and nothing at all to
// a screen reader without this. 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._models3dBtn?.setAttribute(
'aria-pressed',
String(this._models3dEnabled),
);
// 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();
}
}
+320 -301
View File
File diff suppressed because it is too large. Load diff
+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',
);
});
+20 -41
View File
@@ -239,18 +239,20 @@ export function createCameraOrientationAnimator(
};
}
/** Bind the two map-orientation actions and keep their accessible state current. */
/**
* 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,
elements,
publish,
runNavigation,
showToast,
}) {
const tiltButton = elements?.tiltButton;
const northButton = elements?.northButton;
const removers = [];
let destroyed = false;
let applied = null;
// 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.
@@ -265,12 +267,14 @@ export function bindCameraOrientationControls({
};
/**
* Write the two controls' state, but only what actually changed.
* 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;
@@ -278,40 +282,11 @@ export function bindCameraOrientationControls({
tilted,
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
};
if (
applied &&
applied.tilted === next.tilted &&
applied.heading === next.heading
)
return;
if (!applied || applied.tilted !== next.tilted) {
tiltButton?.setAttribute('aria-pressed', String(next.tilted));
tiltButton?.setAttribute(
'aria-label',
next.tilted
? t('Return map to straight-down view')
: t('Tilt map to oblique view'),
);
}
if (!applied || applied.heading !== next.heading) {
northButton?.setAttribute('data-north-up', String(next.heading === 0));
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
t('Reset map to north up. Current heading {heading} degrees', {
heading: next.heading,
}),
);
}
applied = next;
publish?.(next);
}
const listen = (element, handler) => {
if (!element) return;
element.addEventListener('click', handler);
removers.push(() => element.removeEventListener('click', handler));
};
listen(tiltButton, () => {
function tilt() {
if (destroyed) return;
const pending = animator.destination;
const result = runNavigation('camera', () => {
const frame = readCameraTargetFrame(viewer);
@@ -332,8 +307,10 @@ export function bindCameraOrientationControls({
tilted = result.tilted;
}
sync();
});
listen(northButton, () => {
}
function northUp() {
if (destroyed) return;
const pending = animator.destination;
const result = runNavigation('camera', () => {
const frame = readCameraTargetFrame(viewer);
@@ -347,7 +324,7 @@ export function bindCameraOrientationControls({
});
if (result) showToast?.(t('North up'));
sync();
});
}
const subscribe = (event, handler) => {
const remove = event?.addEventListener?.(handler);
@@ -369,6 +346,8 @@ export function bindCameraOrientationControls({
refreshTilt();
return {
tilt,
northUp,
sync,
refreshTilt,
cancel: animator.cancel,
+67 -84
View File
@@ -49,23 +49,25 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
return { viewer, calls, target: surface };
}
class FakeButton extends EventTarget {
constructor() {
super();
this.attributes = new Map();
this.style = {
setProperty: (name, value) => this.attributes.set(name, value),
};
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name);
}
click() {
this.dispatchEvent(new Event('click'));
}
/**
* 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;
}
/**
@@ -277,29 +279,29 @@ 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 tiltButton = new FakeButton();
const bar = recorder();
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton: new FakeButton() },
publish: bar.publish,
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(
tiltButton.getAttribute('aria-pressed'),
'false',
bar.state.tilted,
false,
`straight down at ${range} m must not read as tilted`,
);
tiltButton.click();
controls.tilt();
assert.equal(
tiltButton.getAttribute('aria-pressed'),
'true',
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(
tiltButton.getAttribute('aria-pressed'),
'true',
bar.state.tilted,
true,
`the settled state at ${range} m must agree with the commanded one`,
);
controls.destroy();
@@ -309,13 +311,13 @@ test('the button shows the state the next click will produce, at every range', (
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 northButton = new FakeButton();
const bar = recorder();
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton: new FakeButton(), northButton },
publish: bar.publish,
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
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.
@@ -326,23 +328,23 @@ test('the needle follows a rotation too small for the camera-changed threshold',
orbit(viewer, AUSTIN_GROUND, 10, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(
northButton.getAttribute('--camera-heading'),
'10deg',
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(northButton.getAttribute('--camera-heading'), '13deg');
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 northButton = new FakeButton();
const bar = recorder();
const controls = bindCameraOrientationControls({
viewer,
elements: { northButton },
publish: bar.publish,
runNavigation: (_noun, navigate) => navigate(),
});
for (const [heading, active] of [
@@ -355,66 +357,49 @@ test('north-up indicator follows real heading including the 360-degree seam', ()
]) {
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(
northButton.getAttribute('data-north-up'),
String(active),
`heading ${heading}`,
);
assert.equal(bar.state.heading === 0, active, `heading ${heading}`);
}
controls.destroy();
});
test('a settled camera writes nothing, and teardown releases every signal', () => {
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 tiltButton = new FakeButton();
const northButton = new FakeButton();
let writes = 0;
const count = (element) => {
const setAttribute = element.setAttribute.bind(element);
const setProperty = element.style.setProperty.bind(element.style);
element.setAttribute = (name, value) => {
writes += 1;
setAttribute(name, value);
};
element.style.setProperty = (name, value) => {
writes += 1;
setProperty(name, value);
};
};
count(tiltButton);
count(northButton);
const bar = recorder();
const preRenderBefore = viewer.scene.preRender.numberOfListeners;
const moveEndBefore = viewer.camera.moveEnd.numberOfListeners;
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton },
publish: bar.publish,
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore + 1);
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore + 1);
writes = 0;
bar.publishes = 0;
for (let i = 0; i < 60; i += 1) viewer.scene.preRender.raiseEvent();
assert.equal(writes, 0, '60 frames of a settled camera write nothing');
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(writes > 0, 'a rotated camera updates the needle');
writes = 0;
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(writes, 0, 'and then goes quiet again');
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);
writes = 0;
bar.publishes = 0;
orbit(viewer, AUSTIN_GROUND, 120, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
viewer.camera.moveEnd.raiseEvent();
tiltButton.click();
assert.equal(writes, 0, 'a destroyed binding writes nothing');
controls.tilt();
assert.equal(bar.publishes, 0, 'a destroyed binding publishes nothing');
});
test('a sky-facing camera yields no frame and both actions decline', () => {
@@ -487,13 +472,12 @@ test('north-up preserves target pitch and range', () => {
test('bindings route both controls and release every listener on destroy', () => {
const { viewer } = createViewer();
const tiltButton = new FakeButton();
const northButton = new FakeButton();
const bar = recorder();
const navigations = [];
const toasts = [];
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton },
publish: bar.publish,
runNavigation(noun, navigate) {
navigations.push(noun);
return navigate();
@@ -501,20 +485,20 @@ test('bindings route both controls and release every listener on destroy', () =>
showToast: (message) => toasts.push(message),
});
tiltButton.click();
northButton.click();
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(tiltButton.getAttribute('aria-pressed'), 'true');
assert.equal(northButton.getAttribute('--camera-heading'), '90deg');
assert.equal(bar.state.tilted, true);
assert.equal(bar.state.heading, 90);
viewer.camera.heading = -1e-12;
controls.sync();
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
assert.equal(bar.state.heading, 0);
controls.destroy();
tiltButton.click();
controls.tilt();
assert.deepEqual(navigations, ['camera', 'camera']);
assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89));
});
@@ -663,22 +647,21 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt
const { viewer } = createRealViewer(target);
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
viewer.scene.preUpdate = new Cesium.Event();
const tiltButton = new FakeButton();
const northButton = new FakeButton();
const bar = recorder();
let controls;
controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton },
publish: bar.publish,
runNavigation: (_noun, navigate) => {
controls.cancel();
return navigate();
},
});
tiltButton.click();
tiltButton.click();
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
tiltButton.click();
northButton.click();
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();
+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>
+45 -74
View File
@@ -1,63 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CctvControls } from './cctvControls.js';
import { railFixture } from './railTestFixture.mjs';
import { presentCctvSourceBadge } from './cctvFrames.js';
import { presentCctvPanel } from './cctvPresentation.js';
import { setLocale, t } from '../i18n/index.js';
function fixture(t) {
const { document } = railFixture();
const controls = new CctvControls({
elements: {
_cctvQualityChip: document.createElement('span'),
_cctvSourceBadge: document.createElement('div'),
},
cctv: {},
actions: { isEnabled: () => true, setPanelCollapsed() {} },
});
t.after(() => controls.destroy());
return { controls, document };
}
const IDLE = Object.freeze({ loading: false, error: false, hasFrame: false });
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
const { controls, document } = fixture(testContext);
test('source and frame states retain their distinct meanings, including retained pixels', () => {
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
controls._cctvFrame = document.createElement('img');
controls._cctvFrameWrap = document.createElement('div');
controls._cctvFrame.removeAttribute = () => {};
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
node.classList.remove = (...names) =>
names.forEach((name) => node.classList.toggle(name, false));
}
const expect = (label, state, active = camera, enabled = true) => {
controls._syncCctvSourceBadge(active, enabled);
assert.equal(controls._cctvSourceBadge.textContent, label);
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
};
expect('SOURCE · UNKNOWN', 'idle', camera, false);
expect('SOURCE · UNKNOWN', 'idle', null);
controls._cctvFrame.dataset.loading = 'true';
expect('FRAME · LOADING', 'loading');
controls._cctvFrame.dataset.loading = '';
controls._cctvFrame.dataset.error = 'true';
expect('FRAME · UNAVAILABLE', 'error');
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.
controls._cctvFrameWrap.classList.toggle('has-frame', true);
expect('SNAPSHOT · DEGRADED', 'ready');
controls._cctvFrame.dataset.loading = 'true';
expect('SNAPSHOT · DEGRADED', 'ready');
controls._cctvFrame.dataset.loading = '';
controls._cctvFrame.dataset.error = '';
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', {
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', IDLE, {
...camera,
sourceStatus: status,
});
}
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
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', (testContext) => {
const { controls } = fixture(testContext);
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'],
@@ -69,44 +42,42 @@ test('calibration labels follow supplied provenance and dirty/save/reset transit
];
for (const [calBadge, calDirty, label] of cases) {
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
controls._renderCctvState({
enabled: true,
activeCameraId: 'a',
activeCamera,
});
assert.equal(controls._cctvQualityChip.textContent, label);
assert.equal(
controls._cctvQualityChip.dataset.calBadge,
calDirty ? 'edited' : calBadge || '',
const { quality } = presentCctvPanel(
{ enabled: true, activeCameraId: 'a', activeCamera },
true,
);
assert.deepEqual(quality, {
label,
calBadge: calDirty ? 'edited' : calBadge || '',
});
}
controls._renderCctvState(null);
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
assert.deepEqual(presentCctvPanel(null, false).quality, {
label: 'CAL · --',
calBadge: '',
});
});
test('localization changes badge copy while preserving machine state ids', (testContext) => {
const { controls } = fixture(testContext);
testContext.after(() => setLocale('en'));
setLocale('ru');
controls._syncCctvSourceBadge(
const badge = presentCctvSourceBadge(
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
true,
IDLE,
);
assert.equal(
controls._cctvSourceBadge.textContent,
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
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.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
controls._renderCctvState({
enabled: true,
activeCameraId: 'a',
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
assert.deepEqual(quality, {
label: t('CAL · EDITED (UNSAVED)'),
calBadge: 'edited',
});
assert.equal(
controls._cctvQualityChip.textContent,
t('CAL · EDITED (UNSAVED)'),
);
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
});
+9 -3
View File
@@ -59,6 +59,12 @@ export function _initCctvPanel() {
);
});
// 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;
@@ -72,9 +78,9 @@ export function _initCctvPanel() {
(activeId && activeId !== this._cctvState?.activeCameraId)
)
return;
// Picking a camera from the dropdown flies to it. The catalog spans
// three metros, so a bare selection used to leave the view in the old
// city with a camera active thousands of km away.
// 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),
+51 -73
View File
@@ -1,5 +1,4 @@
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
import { createNumberInput } from './formField.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -112,100 +111,79 @@ export function _resetCctvCalibration() {
export function _beginCctvCalValueEdit(chip) {
if (this.destroyed) return;
this._calibrationEdit?.(false);
const field = CCTV_CAL_FIELDS[chip.dataset.calField];
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 };
const editListeners = new AbortController();
const startValue = field.get(activeCamera);
const input = createNumberInput({
className: 'gev-field--inline cctv-cal-input',
step: field.decimals > 0 ? '0.1' : '1',
value: Number(startValue).toFixed(field.decimals),
ariaLabel: t(field.label),
});
chip.textContent = `${t(field.label)} `;
chip.appendChild(input);
input.focus();
input.select();
let finished = false;
const finish = (commit) => {
/**
* 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;
editListeners.abort();
this._calibrationEdit = null;
const typed = parseFloat(input.value);
input.remove();
this.store.setCalEdit(null);
const typed = parseFloat(typedText);
if (
commit &&
!this.destroyed &&
Number.isFinite(typed) &&
this._cctvState?.enabled &&
this.actions.isEnabled() &&
cameraId === this._activeCctvCameraId()
cameraId &&
cameraId === this._activeCctvCameraId() &&
this.actions.setParams
) {
if (cameraId && this.actions.setParams) {
this.actions.setParams(
{
selectedCameraId: cameraId,
calibration: { cameraId, patch: field.toPatch(typed, basePose) },
},
{ origin: 'user' },
);
return; // re-render restores the chip text from fresh state
}
this.actions.setParams(
{
selectedCameraId: cameraId,
calibration: { cameraId, patch: field.toPatch(typed, basePose) },
},
{ origin: 'user' },
);
}
this._syncCctvCalReadout(
!!this._cctvState?.enabled,
this._cctvState?.activeCamera || null,
);
};
this._calibrationEdit = finish;
input.addEventListener(
'keydown',
(event) => {
if (event.key === 'Enter') finish(true);
else if (event.key === 'Escape') {
event.preventDefault();
finish(false);
}
event.stopPropagation();
},
{ signal: editListeners.signal },
);
input.addEventListener('blur', () => finish(true), {
signal: editListeners.signal,
});
input.addEventListener('click', (event) => event.stopPropagation(), {
signal: editListeners.signal,
// 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,
});
}
export function _syncCctvCalReadout(enabled, activeCamera) {
/**
* 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;
if (this._cctvAdjustBtn) {
const adjustOn = !!this._cctvState?.calibrationMode;
this._cctvAdjustBtn.classList.toggle('active', adjustOn && canCalibrate);
this._cctvAdjustBtn.textContent = adjustOn ? t('ADJUST ON') : t('ADJUST');
this._cctvAdjustBtn.disabled = !canCalibrate;
}
if (this._cctvCalReadout) {
for (const chip of this._cctvCalReadout.querySelectorAll(
'.cctv-cal-value',
)) {
if (chip.querySelector('input')) continue; // an edit is in flight — don't clobber
const field = CCTV_CAL_FIELDS[chip.dataset.calField];
if (!field) continue;
const value = canCalibrate ? field.get(activeCamera) : null;
chip.textContent = Number.isFinite(value)
? calValueText(field, value)
: `${t(field.label)} --`;
chip.disabled = !canCalibrate;
}
}
for (const el of [this._cctvCalibSaveBtn, this._cctvCalibResetBtn]) {
if (el) el.disabled = !canCalibrate;
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,
};
}
+41 -7
View File
@@ -1,6 +1,8 @@
import {
EMPTY_CCTV_FRAME,
_clearCctvFrame,
_queueCctvFrame,
_setCctvFrame,
_settleCctvFrame,
_syncCctvSourceBadge,
} from './cctvFrames.js';
@@ -8,20 +10,37 @@ import {
_activeCctvCameraId,
_resetCctvCalibration,
_beginCctvCalValueEdit,
_syncCctvCalReadout,
} 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. */
/**
* 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 }) {
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;
@@ -33,11 +52,22 @@ export class CctvControls {
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),
@@ -73,6 +103,9 @@ export class CctvControls {
_clearCctvFrame(...args) {
return _clearCctvFrame.call(this, ...args);
}
_setCctvFrame(...args) {
return _setCctvFrame.call(this, ...args);
}
_queueCctvFrame(...args) {
return _queueCctvFrame.call(this, ...args);
}
@@ -91,9 +124,6 @@ export class CctvControls {
_beginCctvCalValueEdit(...args) {
return _beginCctvCalValueEdit.call(this, ...args);
}
_syncCctvCalReadout(...args) {
return _syncCctvCalReadout.call(this, ...args);
}
_calBadgeLabel(...args) {
return _calBadgeLabel.call(this, ...args);
}
@@ -103,6 +133,9 @@ export class CctvControls {
_typeCctvSummary(...args) {
return _typeCctvSummary.call(this, ...args);
}
_publishCctvChip(...args) {
return _publishCctvChip.call(this, ...args);
}
_updateCctvSyncChip(...args) {
return _updateCctvSyncChip.call(this, ...args);
}
@@ -112,6 +145,7 @@ export class CctvControls {
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._offLocale();
this._cctvVideoSurface?.stop();
this._videoVisibilityObserver?.disconnect();
this._actionGeneration++;
@@ -124,6 +158,6 @@ export class CctvControls {
clearTimeout(this._cctvChipHideTimer);
this._cctvSummaryTypingTimer = null;
this._cctvChipHideTimer = null;
this._cctvSyncChip?.classList.remove('visible');
this._publishCctvChip({ visible: false, stopped: true });
}
}
+7 -103
View File
@@ -55,8 +55,8 @@ test('a late image completion cannot replace a newer camera preview', (t) => {
assert.equal(requests[0].onload, null);
requests[1].onload();
stale();
assert.equal(controls._cctvFrame.src, 'second.jpg');
assert.equal(controls._cctvFrame.dataset.cameraId, 'b');
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) => {
@@ -65,11 +65,11 @@ test('failed refresh preserves settled pixels, but changing cameras clears them'
requests[0].onload();
controls._queueCctvFrame('refresh.jpg', 'a', false);
requests[1].onerror();
assert.equal(controls._cctvFrame.src, 'first.jpg');
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), true);
assert.equal(controls._frame.src, 'first.jpg');
assert.equal(controls._frame.hasFrame, true);
controls._queueCctvFrame('other.jpg', 'b', true);
assert.equal(controls._cctvFrame.src, '');
assert.equal(controls._cctvFrameWrap.classList.contains('has-frame'), false);
assert.equal(controls._frame.src, '');
assert.equal(controls._frame.hasFrame, false);
});
test('destroy invalidates image callbacks and releases each subscription once', (t) => {
@@ -90,107 +90,11 @@ test('destroy invalidates image callbacks and releases each subscription once',
assert.equal(unsubscribed, 2);
assert.equal(requests[0].onload, null);
assert.equal(requests[0].onerror, null);
assert.equal(controls._cctvFrame.src, '');
assert.equal(controls._frame.src, '');
controls._queueCctvFrame('late.jpg', 'b', true);
assert.equal(requests.length, 1);
});
function calibrationFixture(t) {
const { controls } = fixture(t);
const prior = globalThis.document;
const inputs = [];
globalThis.document = {
createElement() {
const input = new EventTarget();
Object.assign(input, {
focus() {},
select() {},
setAttribute(name, value) {
this[name] = value;
},
remove() {
this.parent.input = null;
},
});
inputs.push(input);
return input;
},
};
t.after(() => {
globalThis.document = prior;
});
const chip = {
dataset: { calField: 'heading' },
textContent: '',
input: null,
appendChild(input) {
this.input = input;
input.parent = this;
},
querySelector() {
return this.input;
},
};
const patches = [];
controls.actions.setParams = (params) => patches.push(params);
controls.actions.setPanelCollapsed = () => {};
controls._cctvCalReadout = { querySelectorAll: () => [chip] };
controls._cctvState = {
enabled: true,
activeCameraId: 'a',
activeCamera: { id: 'a', headingDeg: 30, basePose: { headingDeg: 20 } },
};
const key = (name) =>
Object.assign(new Event('keydown', { cancelable: true }), { key: name });
return { controls, chip, inputs, patches, key };
}
test('calibration commits against the captured camera base and releases its editor', (t) => {
const { controls, chip, inputs, patches, key } = calibrationFixture(t);
controls._beginCctvCalValueEdit(chip);
inputs[0].value = '100';
controls._cctvState.activeCamera.basePose.headingDeg = 60;
inputs[0].dispatchEvent(key('Enter'));
inputs[0].dispatchEvent(new Event('blur'));
assert.deepEqual(patches, [
{
selectedCameraId: 'a',
calibration: { cameraId: 'a', patch: { headingDeg: 80 } },
},
]);
assert.equal(controls._calibrationEdit, null);
});
test('a camera switch cancels calibration before old blur can change the new camera', (t) => {
const { controls, chip, inputs, patches } = calibrationFixture(t);
controls._beginCctvCalValueEdit(chip);
inputs[0].value = '100';
controls._renderCctvState({
enabled: true,
activeCameraId: 'b',
activeCamera: { id: 'b', headingDeg: 200, basePose: { headingDeg: 190 } },
});
inputs[0].dispatchEvent(new Event('blur'));
assert.deepEqual(patches, []);
assert.equal(chip.input, null);
assert.equal(chip.textContent, 'HDG 200.0°');
});
test('Escape claims calibration cancellation and disposal prevents late commits', (t) => {
const { controls, chip, inputs, patches, key } = calibrationFixture(t);
controls._beginCctvCalValueEdit(chip);
inputs[0].value = '100';
const escape = key('Escape');
inputs[0].dispatchEvent(escape);
inputs[0].dispatchEvent(new Event('blur'));
assert.equal(escape.defaultPrevented, true);
controls._beginCctvCalValueEdit(chip);
inputs[1].value = '120';
controls.destroy();
inputs[1].dispatchEvent(new Event('blur'));
assert.deepEqual(patches, []);
});
test('disposing during camera enable prevents the delayed focus and future clicks', async () => {
const button = new EventTarget();
let resolveEnable;
+61 -70
View File
@@ -1,4 +1,3 @@
import { updateStatusBadge } from './statusBadge.js';
import { msg, t } from '../i18n/index.js';
/**
@@ -78,6 +77,23 @@ export function cctvSourceLabelText(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) {
@@ -85,41 +101,29 @@ export function _clearCctvFrame() {
this._cctvFramePreloader.onerror = null;
}
this._cctvFramePreloader = null;
if (this._cctvFrame) {
this._cctvFrame.classList.remove('active');
this._cctvFrame.removeAttribute('src');
this._cctvFrame.dataset.cameraId = '';
this._cctvFrame.dataset.currentSrc = '';
this._cctvFrame.dataset.loading = '';
this._cctvFrame.dataset.error = '';
}
this._cctvFrameWrap?.classList.remove('loading', 'has-frame');
if (this._cctvFrame) this._setCctvFrame(EMPTY_CCTV_FRAME);
}
export function _queueCctvFrame(src, cameraId, cameraChanged) {
if (this.destroyed || !this._cctvFrame || !src) return;
if (cameraChanged) {
// A different camera gets an honest acquisition state. Never retain
// the prior camera's pixels under the newly selected metadata.
this._cctvFrame.classList.remove('active');
this._cctvFrame.removeAttribute('src');
this._cctvFrameWrap?.classList.remove('has-frame');
}
if (this._cctvFramePreloader) {
this._cctvFramePreloader.onload = null;
this._cctvFramePreloader.onerror = null;
}
const token = ++this._cctvFrameRequestToken;
this._cctvFrame.dataset.cameraId = cameraId;
this._cctvFrame.dataset.currentSrc = src;
this._cctvFrame.dataset.loading = 'true';
this._cctvFrame.dataset.error = '';
this._cctvFrameWrap?.classList.toggle(
'loading',
!this._cctvFrameWrap?.classList.contains('has-frame'),
);
// 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;
@@ -140,54 +144,41 @@ export function _settleCctvFrame(token, src, ok) {
this._cctvFramePreloader.onerror = null;
}
this._cctvFramePreloader = null;
this._cctvFrame.dataset.loading = '';
this._cctvFrameWrap?.classList.remove('loading');
const syncBadge = () =>
this._syncCctvSourceBadge(
this._cctvState?.activeCamera,
!!this._cctvState?.enabled && !!this.actions.isEnabled(),
);
if (!ok) {
// Leave the element untouched — a settled frame stays on screen.
this._cctvFrame.dataset.error = 'true';
syncBadge();
return;
}
this._cctvFrame.dataset.error = '';
this._cctvFrame.src = src;
this._cctvFrame.classList.add('active');
this._cctvFrameWrap?.classList.add('has-frame');
syncBadge();
// 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(),
);
}
export function _syncCctvSourceBadge(activeCamera, enabled) {
if (!this._cctvSourceBadge) return;
if (!enabled || !activeCamera) {
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
this._cctvSourceBadge.dataset.frameState = 'idle';
return;
}
const hasDisplayedFrame =
this._cctvFrameWrap?.classList.contains('has-frame');
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
this._cctvSourceBadge.dataset.frameState = 'loading';
return;
}
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
updateStatusBadge(this._cctvSourceBadge, {
label: t('FRAME · UNAVAILABLE'),
});
this._cctvSourceBadge.dataset.frameState = 'error';
return;
}
/** 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');
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
this._cctvSourceBadge.dataset.frameState = 'ready';
return { label: `${kind} · ${status}`, state: 'ready' };
}
export function _syncCctvSourceBadge(activeCamera, enabled) {
this.store.setSourceBadge(
presentCctvSourceBadge(activeCamera, enabled, this._frame),
);
}
+492
View File
@@ -0,0 +1,492 @@
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);
});
+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');
});
+223 -161
View File
@@ -1,8 +1,8 @@
import { updateStatusBadge } from './statusBadge.js';
import { keepCodesWithValues, t, tn } from '../i18n/index.js';
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';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
@@ -16,6 +16,123 @@ export function _calBadgeLabel(badge) {
}
}
/** 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: `${Math.round(activeCamera.headingDeg)}°`,
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 (
@@ -26,7 +143,13 @@ export function _renderCctvState(state) {
this._calibrationEdit?.(false);
}
this._cctvState = state || null;
const cameras = state?.cameras || [];
// 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;
@@ -53,158 +176,73 @@ export function _renderCctvState(state) {
this._updateCctvSyncChip(state?.loading, enabled);
if (this._cctvEnableBtn) {
this._cctvEnableBtn.classList.toggle('active', enabled);
this._cctvEnableBtn.textContent = enabled ? t('CCTV ON') : t('CCTV OFF');
}
if (this._cctvSelect) {
const shouldRebuild =
this._cctvSelect.options.length !== cameras.length ||
cameras.some(
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
);
if (shouldRebuild) {
this._cctvSelect.innerHTML = '';
for (const camera of cameras) {
const option = document.createElement('option');
option.value = camera.id;
option.textContent = `${camera.city} · ${camera.name}`;
this._cctvSelect.appendChild(option);
}
}
this._cctvSelect.disabled = !enabled || cameras.length === 0;
// 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 &&
Array.from(this._cctvSelect.options).some((opt) => opt.value === activeId)
activeCamera &&
!picker?.options.some((option) => option.value === activeId)
) {
this._cctvSelect.value = activeId;
} else if (!activeId) {
this._cctvSelect.selectedIndex = -1;
// 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) : '',
};
}
}
for (const btn of [
this._cctvNearestBtn,
this._cctvPrevBtn,
this._cctvNextBtn,
]) {
if (!btn) continue;
btn.disabled = !enabled || cameras.length === 0;
}
if (this._cctvFocusBtn) {
this._cctvFocusBtn.disabled = !enabled || cameras.length === 0 || !activeId;
}
if (this._cctvCoverageBtn) {
// Tri-state (viewshed design §3b): off → on (wireframes) → viewshed
// (color-coded volumes). The click handler cycles; this renders.
const mode = state?.coverageMode || (state?.showCoverage ? 'on' : 'off');
this._cctvCoverageBtn.classList.toggle('active', mode !== 'off');
this._cctvCoverageBtn.textContent =
mode === 'viewshed'
? t('VIEWSHED ON')
: mode === 'on'
? t('COVERAGE ON')
: t('COVERAGE OFF');
this._cctvCoverageBtn.disabled = !enabled;
}
if (this._cctvAutoHopBtn) {
const autoHop = !!state?.autoHop;
this._cctvAutoHopBtn.classList.toggle('active', autoHop);
this._cctvAutoHopBtn.textContent = autoHop
? t('AUTO HOP ON')
: t('AUTO HOP OFF');
this._cctvAutoHopBtn.disabled = !enabled;
}
if (this._cctvProjectionBtn) {
const showProjection = state?.showProjection !== false;
this._cctvProjectionBtn.classList.toggle('active', showProjection);
this._cctvProjectionBtn.textContent = showProjection
? t('PROJECTION ON')
: t('PROJECTION OFF');
this._cctvProjectionBtn.disabled = !enabled;
}
if (this._cctvQualityChip) {
// CAL badge (cctv-v2 design §3b, amended by LOCKED §9.2 — panel-only,
// no in-world tint): three states driven by cctv.js's deriveCalBadge,
// no client-side scoring math. Casing is unified via _calBadgeLabel so
// the chip and the meta line never drift onto different conventions.
// Save-gated persistence (viewshed design §3e): unsaved live edits show
// EDITED on top of whatever the persisted badge state is — SAVE CAL
// promotes to CALIBRATED, RESET CAL clears.
const badge = activeCamera?.calBadge || null;
const dirty = !!activeCamera?.calDirty;
updateStatusBadge(this._cctvQualityChip, {
label: dirty
? t('CAL · EDITED (UNSAVED)')
: t('CAL · {badge}', {
badge: this._calBadgeLabel(badge),
}),
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._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
}
this._syncCctvCalReadout(enabled, activeCamera);
if (this._cctvMeta) {
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
? this._calBadgeLabel(activeCamera.calBadge)
: '';
const projLabel =
state?.showProjection !== false ? t('MONITOR') : t('OFF');
this._cctvMeta.textContent = keepCodesWithValues(
t(
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg)}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
calBadge: calBadge ? ` · ${calBadge}` : '',
provider,
status: `${credit}${statusMsg}`,
},
),
);
} else if (cameras.length > 0) {
this._cctvMeta.textContent = enabled
? tn(
cameras.length,
'{count} camera loaded · click a camera to activate',
'{count} cameras loaded · click a camera to activate',
)
: tn(
cameras.length,
'{count} camera loaded · enable CCTV to activate',
'{count} cameras loaded · enable CCTV to activate',
);
} else {
this._cctvMeta.textContent = t(
'Enable CCTV to load camera intersections',
);
}
}
this.store.publish(presentCctvPanel(state, enabled));
const liveIntent = enabled && !!activeCamera?.isVideo;
this.store.setLive(liveIntent);
if (this._cctvVideo) {
this._cctvVideo.hidden = !liveIntent;
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
const visible =
liveIntent &&
!document.hidden &&
@@ -224,16 +262,15 @@ export function _renderCctvState(state) {
if (this._cctvFrame && !liveIntent) {
const nextSrc = enabled ? activeCamera?.frameUrl : null;
const nextCameraId = enabled ? activeCamera?.id || '' : '';
const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
const frameLoading = this._cctvFrame.dataset.loading === 'true';
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._cctvFrame.dataset.currentSrc !== nextSrc))
(cameraChanged || (!frameLoading && this._frame.currentSrc !== nextSrc))
) {
this._queueCctvFrame(nextSrc, nextCameraId, cameraChanged);
}
@@ -258,25 +295,46 @@ export function _typeCctvSummary(text) {
this._lastCctvSummaryText = nextText;
clearInterval(this._cctvSummaryTypingTimer);
this._cctvSummary.textContent = '';
this.store.setSummary('');
let idx = 0;
this._cctvSummaryTypingTimer = setInterval(() => {
if (this.destroyed) return;
idx += 3;
if (idx >= nextText.length) {
this._cctvSummary.textContent = nextText;
this.store.setSummary(nextText);
clearInterval(this._cctvSummaryTypingTimer);
this._cctvSummaryTypingTimer = null;
return;
}
this._cctvSummary.textContent = nextText.slice(0, idx);
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;
if (!this._cctvSyncChip || !this._cctvSyncLabel || !this._cctvSyncProgress)
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;
@@ -285,31 +343,35 @@ export function _updateCctvSyncChip(loading, enabled) {
clearTimeout(this._cctvChipHideTimer);
this._cctvChipHideTimer = null;
this._cctvChipWasBusy = true;
this.actions.setSplitFlapText(this._cctvSyncLabel, t('loading frames'));
// 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._cctvSyncProgress.textContent = `${loaded}/${total}`;
this._cctvSyncChip.classList.add('visible');
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.actions.setSplitFlapText(this._cctvSyncLabel, t('camera grid ready'));
this._cctvSyncProgress.textContent = `${total}/${total}`;
this._cctvSyncChip.classList.add('visible');
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._cctvSyncChip.classList.remove('visible');
this._publishCctvChip({ visible: false });
}, 1500);
return;
}
if (!this._cctvChipHideTimer) {
this._cctvChipWasBusy = false;
this._cctvSyncChip.classList.remove('visible');
this._publishCctvChip({ visible: false });
}
}
-53
View File
@@ -1,53 +0,0 @@
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
const attribute = (node, key, value) => {
if (node.getAttribute(key) !== value) node.setAttribute(key, value);
};
/** Keyed chips shared by layer rows and rail cards; the caller owns dispatch. */
export function syncChipGroup(container, chips = [], { before = null } = {}) {
if (!container) return;
const document = container.ownerDocument ?? globalThis.document;
const stale = new Map();
for (const node of [...container.children])
if (node.dataset?.chipId) stale.set(node.dataset.chipId, node);
const ordered = [];
for (const chip of chips) {
let button = stale.get(chip.id);
stale.delete(chip.id);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.dataset.chipId = chip.id;
}
const state = chip.state || (chip.active ? 'active' : 'idle');
set(
button,
'className',
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
);
set(button, 'textContent', chip.label);
// Optional per-chip colour; CSS decides how it is used.
const color = chip.color || '';
if ((button.dataset.chipColor || '') !== color) {
if (color) {
button.style.setProperty('--chip-color', color);
button.dataset.chipColor = color;
} else {
button.style.removeProperty('--chip-color');
delete button.dataset.chipColor;
}
}
set(button, 'title', chip.title || '');
set(button, 'disabled', Boolean(chip.disabled));
attribute(button, 'aria-pressed', String(Boolean(chip.active)));
attribute(button, 'aria-busy', String(Boolean(chip.busy)));
ordered.push(button);
}
for (const node of stale.values()) node.remove();
ordered.forEach((node, index) => {
if (container.children[index] !== node)
container.insertBefore(node, container.children[index] || before);
});
}
-54
View File
@@ -1,54 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { syncChipGroup } from './chipGroup.js';
import { railFixture } from './railTestFixture.mjs';
test('keyed chips retain focus, reflect state, reorder and remove without redundant writes', () => {
let writes = 0;
const f = railFixture(() => writes++);
const a = { id: 'a', label: 'GFS', active: true };
const b = { id: 'b', label: 'ECMWF' };
const legend = f.document.createElement('span');
f.container.appendChild(legend);
syncChipGroup(f.container, [a, b], { before: legend });
const first = f.container.children[0];
first.focus();
const next = [{ ...a, label: 'Changed', busy: true, disabled: true }, b];
syncChipGroup(f.container, next);
assert.equal(f.container.children[0], first);
assert.equal(f.document.activeElement, first);
assert.equal(first.disabled, true);
assert.equal(first.getAttribute('aria-busy'), 'true');
assert.equal(first.getAttribute('aria-pressed'), 'true');
writes = 0;
syncChipGroup(f.container, next);
assert.equal(writes, 0);
syncChipGroup(f.container, [b, a]);
assert.equal(f.container.children[1], first);
assert.equal(f.container.children[2], legend);
syncChipGroup(f.container, [a]);
assert.deepEqual(f.container.children, [first, legend]);
syncChipGroup(f.container, []);
assert.deepEqual(f.container.children, [legend]);
});
test('a chip colour is set once, changed, and cleared through a CSS variable', () => {
const f = railFixture(() => {});
const calls = [];
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
const chip = f.container.children[0];
chip.style.setProperty = (name, value) => calls.push(['set', name, value]);
chip.style.removeProperty = (name) => calls.push(['remove', name]);
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
assert.deepEqual(calls, [], 'a chip without a colour never touches style');
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#05cb63' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M', color: '#a66bff' }]);
syncChipGroup(f.container, [{ id: 'm', label: 'M' }]);
assert.deepEqual(calls, [
['set', '--chip-color', '#05cb63'],
['set', '--chip-color', '#a66bff'],
['remove', '--chip-color'],
]);
assert.equal(chip.dataset.chipColor, undefined);
});
+7 -1
View File
@@ -1,6 +1,12 @@
import { t } from '../i18n/index.js';
/** Keep the clear action focusable while busy; the caller owns its transaction. */
/**
* 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 = () => {
+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: '—',
},
};
}
+128 -146
View File
@@ -2,15 +2,17 @@
import { formatNumber, t, msg } from '../i18n/index.js';
import {
COCKPIT_BRIEF_ROTATE_MS,
COCKPIT_BRIEF_CYCLE_OFF_HELP,
COCKPIT_BRIEF_CYCLE_ON_HELP,
COCKPIT_REGIONAL_REFRESH_MS,
COCKPIT_REGIONAL_REFRESH_DISTANCE_M,
COCKPIT_BRIEF_PAGES,
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.
@@ -29,72 +31,73 @@ const WEATHER_CONDITION_KEYS = Object.freeze({
'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];
this.briefPages.forEach((element) => {
element.hidden = element.dataset.cockpitBriefPage !== page.id;
});
this.briefTabs.forEach((button) => {
const current =
Number(button.dataset.cockpitBriefIndex) === this.briefPageIndex;
button.setAttribute('aria-current', current ? 'true' : 'false');
});
if (this.briefKicker) {
const indicator = this.briefKicker.querySelector('i');
this.briefKicker.replaceChildren(
...[indicator, document.createTextNode(` ${t(page.kickerKey)}`)].filter(
Boolean,
),
);
}
if (this.briefSubtitle) this.briefSubtitle.textContent = t(page.subtitleKey);
if (this.briefPosition)
this.briefPosition.textContent = `${this.briefPageIndex + 1} / ${count}`;
if (this.briefSource)
this.briefSource.textContent = page.sourceKey
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;
if (this.signalStream) this.signalStream.dataset.briefPage = page.id;
: 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);
if (this.briefAutoToggle) {
this.briefAutoToggle.setAttribute(
'aria-pressed',
String(this.briefAutoRotateEnabled),
);
const label = this.briefAutoRotateEnabled ? 'CYCLE ON' : 'CYCLE OFF';
this.briefAutoToggle.textContent = label;
const help = this.briefAutoRotateEnabled
? COCKPIT_BRIEF_CYCLE_ON_HELP
: COCKPIT_BRIEF_CYCLE_OFF_HELP;
this.briefAutoToggle.setAttribute('aria-label', label);
this.briefAutoToggle.title = help;
// cockpitMarkup.test.mjs pins the English writes above; these translate
// them.
const labelKey = this.briefAutoRotateEnabled
? msg('CYCLE ON')
: msg('CYCLE OFF');
this.briefAutoToggle.textContent = t(labelKey);
this.briefAutoToggle.setAttribute('aria-label', t(labelKey));
this.briefAutoToggle.title = t(
this.briefAutoRotateEnabled
? msg(
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.',
)
: 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.',
),
);
}
Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled));
this.publishBriefView();
if (this.briefAutoRotateEnabled) this.startBriefRotation({ reset: true });
else this.stopBriefRotation();
}
@@ -129,15 +132,20 @@ export function stopBriefRotation() {
this.briefTimer = null;
}
export function updateLocalPosition(info) {
if (!this.localCoordinates) return;
if (!Number.isFinite(info.latitude) || !Number.isFinite(info.longitude)) {
this.localCoordinates.textContent = t('POSITION UNAVAILABLE');
return;
}
/** @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');
this.localCoordinates.textContent = `${lat} · ${lon}`;
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) {
@@ -208,105 +216,79 @@ export function maybeRefreshRegionalBrief(info) {
}
export function renderRegionalBriefStatus(status, info) {
if (this.newsStatus) {
this.newsStatus.hidden = false;
this.newsStatus.dataset.state = status;
this.newsStatus.textContent =
status === 'loading'
? t('ACQUIRING REGIONAL NEWS')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (status === 'unavailable') this.newsList?.replaceChildren();
if (this.localPlace && status === 'loading')
this.localPlace.textContent = t('RESOLVING REGION');
if (this.localPlace && status === 'unavailable')
this.localPlace.textContent = t('REGION UNAVAILABLE');
this.updateLocalPosition(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 : [];
if (this.newsStatus) {
this.newsStatus.hidden = articles.length > 0;
this.newsStatus.dataset.state = payload?.newsStatus || 'unavailable';
this.newsStatus.textContent =
payload?.newsStatus === 'empty'
? t('NO RECENT LOCATION MATCHES')
: t('REGIONAL NEWS UNAVAILABLE');
}
if (this.newsList) {
this.newsList.replaceChildren(
...articles.slice(0, 4).map((article) => {
const entry = document.createElement('li');
const link = document.createElement('a');
link.href = article.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
const title = document.createElement('strong');
title.textContent = article.title;
const metadata = document.createElement('span');
metadata.textContent = t('{domain} · {age}', {
domain: article.domain || t('SOURCE'),
age: formatCockpitBriefAge(article.publishedAt),
});
link.append(title, metadata);
entry.append(link);
return entry;
}),
);
}
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');
if (this.localPlace) this.localPlace.textContent = placeLabel.toUpperCase();
this.updateLocalPosition(info);
const weather = payload?.weather;
if (this.localTemperature) {
this.localTemperature.textContent = Number.isFinite(weather?.temperatureC)
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`
: '—';
}
if (this.localWind) {
this.localWind.textContent = Number.isFinite(weather?.windKph)
: '—',
wind: Number.isFinite(weather?.windKph)
? t('{value} KM/H', { value: Math.round(weather.windKph) })
: '—';
}
if (this.localWindDirection) {
this.localWindDirection.textContent = formatCockpitWindDirection(
weather?.windDirectionDeg,
);
}
if (this.localCondition) {
const condition = this.services.weatherCodeLabel(weather?.weatherCode);
const conditionKey = WEATHER_CONDITION_KEYS[condition];
this.localCondition.textContent = conditionKey
? t(conditionKey)
: condition;
}
if (this.localCloud) {
this.localCloud.textContent = Number.isFinite(weather?.cloudCoverPct)
? t('CLOUD {pct}%', {
pct: Math.round(weather.cloudCoverPct),
})
: t('CLOUD UNKNOWN');
}
if (this.localPrecipitation) {
this.localPrecipitation.textContent = Number.isFinite(
weather?.precipitationMm,
)
: '—',
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,
})
: '—';
}
if (this.signalStream)
this.signalStream.dataset.regionalStatus = payload?.status || 'partial';
if (this.briefPageIndex === 1 && this.briefSource) {
this.briefSource.textContent = t('{source} · LOCATION QUERY', {
: '—',
};
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');
});
+86 -87
View File
@@ -24,15 +24,36 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
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.context.hidden = true;
this.hud?.classList.remove('context-active');
this.showContextCard(false);
this.contextLayoutStamp = null;
this.pushCockpitSignal(
'context-status',
@@ -43,37 +64,31 @@ export function updateContext(info, heading) {
return;
}
this.context.hidden = false;
this.hud?.classList.add('context-active');
if (this.contextSubject) {
const installationCoverage = snapshot.cohorts.find(
(cohort) => cohort.id === 'military-installations',
)?.coverage;
this.contextSubject.textContent = formatCockpitContextScope(
snapshot.subject.label || trackedId,
snapshot.radiusM,
installationCoverage,
LOCALIZED_CONTEXT_SCOPE,
);
}
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.
if (this.contextPrevious)
this.contextPrevious.disabled = !snapshot.navigation?.canPrevious;
if (this.contextNext)
this.contextNext.disabled = !snapshot.navigation?.canNext;
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 = this.context.dataset.state !== 'lost';
this.context.dataset.state = 'lost';
if (this.contextUncertainty) {
this.contextUncertainty.textContent = t(
'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR',
);
}
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();
@@ -91,45 +106,38 @@ export function updateContext(info, heading) {
let unknownCount = 0;
const nearest = [];
for (const cohort of snapshot.cohorts) {
const element = this.contextCohorts.get(cohort.id);
const value = element?.querySelector('strong');
if (value)
value.textContent = cohort.count === null ? '?' : String(cohort.count);
element?.classList.toggle('unknown', cohort.relationship === 'UNKNOWN');
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);
if (this.contextNearestLabel) {
this.contextNearestLabel.textContent = closest
? t('{cohort} · {contact}', {
cohort: closest.cohort.label.toUpperCase(),
contact: closestLabel,
})
: t('NO AVAILABLE EXAMPLE');
this.contextNearestLabel.setAttribute(
'aria-label',
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,
})
: this.contextNearestLabel.textContent,
);
}
if (this.contextDistance) {
const distanceM = closest?.distanceM;
this.contextDistance.textContent = Number.isFinite(distanceM)
? formatCockpitDistance(distanceM)
: '—';
this.contextDistance.setAttribute(
'aria-label',
Number.isFinite(distanceM)
? this.contextDistance.textContent
: t('Unavailable'),
);
}
? 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
@@ -154,36 +162,27 @@ export function updateContext(info, heading) {
: null;
relative = relativeBearing(bearing, heading);
}
if (this.contextDirection) {
this.contextDirection.style.transform = `rotate(${relative ?? 0}deg)`;
this.contextDirection.classList.toggle('unknown', relative === null);
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')}°`,
});
}
if (this.contextBearing) {
if (relative === null) this.contextBearing.textContent = t('BRG —');
else if (Math.abs(relative) < 8)
this.contextBearing.textContent = t('AHEAD');
else {
this.contextBearing.textContent = t('{side} {angle}', {
side: relative < 0 ? t('L') : t('R'),
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
}
if (this.contextUncertainty) {
this.contextUncertainty.textContent = 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');
}
if (this.contextUpdated) {
this.contextUpdated.textContent = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
}
this.context.dataset.state = unknownCount ? 'uncertain' : 'current';
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;
+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,
};
}
+103 -84
View File
@@ -5,6 +5,7 @@ import {
toggleTrackedTr3b,
syncTr3bToggle,
syncEntry,
publishPresenceView,
navigateContext,
_adoptTrackedEntity,
enter,
@@ -12,6 +13,8 @@ import {
} from './cockpitTrackingController.js';
import { update } from './cockpitCamera.js';
import {
publishInstrumentsView,
publishRouteView,
updateHud,
updateRoute,
syncWeatherToggle,
@@ -19,8 +22,21 @@ import {
cycleVisionMode,
clearPredictiveRoute,
} from './cockpitInstruments.js';
import { updateContext } from './cockpitContext.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,
@@ -29,10 +45,11 @@ import {
maybeRefreshRegionalBrief,
renderRegionalBriefStatus,
renderRegionalBrief,
relocalizeBrief,
} from './cockpitBriefing.js';
import {
handleSignalClick,
renderCockpitSignals,
publishCockpitSignals,
pushCockpitSignal,
updateCockpitSignals,
} from './cockpitSignals.js';
@@ -55,9 +72,11 @@ export class CockpitViewController {
getInitialVisionMode = null,
onCameraTakeover = null,
isEntryAllowed = null,
isUtilityDisclosureOpen = null,
onEntered = null,
onExited = null,
restoreTrackingFrame = null,
publish = {},
} = {},
) {
this.viewer = viewer;
@@ -77,47 +96,14 @@ export class CockpitViewController {
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.lastCompassSignature = '';
this.entry = document.getElementById('cockpit-entry');
this.tr3bToggle = document.getElementById('tr3b-toggle');
this._tr3bSignature = null;
this.mapViewButton = document.getElementById('map-view-switch');
this.resetGlobeButton = document.getElementById('cockpit-reset-globe');
this.hud = document.getElementById('cockpit-hud');
this.entryFocusOrigin = null;
this.callsign = document.getElementById('cockpit-callsign');
this.speed = document.getElementById('cockpit-speed-value');
this.speedRim = document.getElementById('cockpit-speed-rim');
this.speedRimValue = document.getElementById('cockpit-speed-rim-value');
this.speedRimTicks = Array.from(
document.querySelectorAll('[data-speed-rim-tick]'),
);
this.altitude = document.getElementById('cockpit-altitude-value');
this.altitudeRim = document.getElementById('cockpit-altitude-rim');
this.altitudeRimValue = document.getElementById(
'cockpit-altitude-rim-value',
);
this.altitudeRimTicks = Array.from(
document.querySelectorAll('[data-altitude-rim-tick]'),
);
this.headingValue = document.getElementById('cockpit-heading-value');
this.compassTape = document.getElementById('cockpit-compass-tape');
this.clock = document.getElementById('cockpit-clock');
this.position = document.getElementById('cockpit-position');
this.aircraftMeta = document.getElementById('cockpit-aircraft-meta');
this.route = document.getElementById('cockpit-route');
this.routeFrom = document.getElementById('cockpit-route-from');
this.routeTo = document.getElementById('cockpit-route-to');
this.routeStatus = document.getElementById('cockpit-route-status');
this.routeDirection = document.getElementById('cockpit-route-direction');
this.routeDirectionLabel = document.getElementById(
'cockpit-route-direction-label',
);
this.visionPrevious = document.getElementById('cockpit-vision-previous');
this.visionCurrent = document.getElementById('cockpit-vision-current');
this.visionCurrentLabel = document.getElementById(
'cockpit-vision-current-label',
);
this.visionNext = document.getElementById('cockpit-vision-next');
this.visionMode = 'optical';
this.onVisionChange = onVisionChange;
@@ -134,71 +120,54 @@ export class CockpitViewController {
: () => false;
this.isEntryAllowed =
typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true;
this.isUtilityDisclosureOpen =
typeof isUtilityDisclosureOpen === 'function'
? isUtilityDisclosureOpen
: () => false;
this.context = document.getElementById('cockpit-context');
this.contextSubject = document.getElementById('cockpit-context-subject');
this.contextNearestLabel = document.getElementById(
'cockpit-context-nearest-label',
);
this.contextBearing = document.getElementById('cockpit-context-bearing');
this.contextDistance = document.getElementById('cockpit-context-distance');
this.contextDirection = document.getElementById(
'cockpit-context-direction',
);
this.contextUncertainty = document.getElementById(
'cockpit-context-uncertainty',
);
this.contextUpdated = document.getElementById('cockpit-context-updated');
this.contextCohorts = new Map(
Array.from(document.querySelectorAll('[data-context-cohort]')).map(
(element) => [element.dataset.contextCohort, element],
),
);
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.weatherState = document.getElementById('cockpit-weather-state');
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.briefKicker = document.getElementById('cockpit-brief-kicker');
this.briefSubtitle = document.getElementById('cockpit-brief-subtitle');
this.briefPrevious = document.getElementById('cockpit-brief-previous');
this.briefNext = document.getElementById('cockpit-brief-next');
this.briefAutoToggle = document.getElementById('cockpit-brief-auto');
this.briefPosition = document.getElementById('cockpit-brief-position');
this.briefSource = document.getElementById('cockpit-brief-source');
this.briefPages = Array.from(
document.querySelectorAll('[data-cockpit-brief-page]'),
);
this.briefTabs = Array.from(
document.querySelectorAll('[data-cockpit-brief-index]'),
);
this.newsStatus = document.getElementById('cockpit-news-status');
this.newsList = document.getElementById('cockpit-news-list');
this.localPlace = document.getElementById('cockpit-local-place');
this.localCoordinates = document.getElementById(
'cockpit-local-coordinates',
);
this.localTemperature = document.getElementById(
'cockpit-local-temperature',
);
this.localWind = document.getElementById('cockpit-local-wind');
this.localWindDirection = document.getElementById(
'cockpit-local-wind-direction',
);
this.localCondition = document.getElementById('cockpit-local-condition');
this.localCloud = document.getElementById('cockpit-local-cloud');
this.localPrecipitation = document.getElementById(
'cockpit-local-precipitation',
);
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;
@@ -223,7 +192,7 @@ export class CockpitViewController {
this.scratchAnchorCartographic = new Cesium.Cartographic();
this.scratchCameraCartographic = new Cesium.Cartographic();
this.scratchTargetCartographic = new Cesium.Cartographic();
this._listenerRemovers = [];
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
@@ -251,8 +220,7 @@ export class CockpitViewController {
this.setContextCollapsed(!this.contextCollapsed),
);
this._listen(this.weatherToggle, 'click', () => {
const enabled =
this.weatherToggle.getAttribute('aria-pressed') !== 'true';
const enabled = !this.contextView.weather;
this.syncWeatherToggle(enabled);
window.dispatchEvent(
new CustomEvent('gev:cockpit-weather-toggle', {
@@ -310,6 +278,9 @@ export class CockpitViewController {
toggleTrackedTr3b() {
return toggleTrackedTr3b.call(this);
}
publishPresenceView() {
return publishPresenceView.call(this);
}
syncTr3bToggle(info) {
return syncTr3bToggle.call(this, info);
}
@@ -331,6 +302,12 @@ export class CockpitViewController {
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);
}
@@ -349,9 +326,18 @@ export class CockpitViewController {
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);
}
@@ -373,11 +359,14 @@ export class CockpitViewController {
renderRegionalBriefStatus(status, info) {
return renderRegionalBriefStatus.call(this, status, info);
}
relocalizeBrief() {
return relocalizeBrief.call(this);
}
renderRegionalBrief(payload, info) {
return renderRegionalBrief.call(this, payload, info);
}
renderCockpitSignals() {
return renderCockpitSignals.call(this);
publishCockpitSignals() {
return publishCockpitSignals.call(this);
}
pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target);
@@ -408,6 +397,36 @@ export class CockpitViewController {
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;
+35
View File
@@ -209,3 +209,38 @@ test('stopped Cockpit cannot rearm briefing rotation or request a fresh region',
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();
}
});
+9
View File
@@ -1,3 +1,4 @@
import { nextTick } from 'vue';
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
@@ -14,6 +15,7 @@ export class CockpitCoordinator {
readDataManager,
readContext,
readActiveStyle,
readUtilityDisclosureOpen,
enterPanels,
exitPanels,
}) {
@@ -25,6 +27,7 @@ export class CockpitCoordinator {
readDataManager,
readContext,
readActiveStyle,
readUtilityDisclosureOpen,
enterPanels,
exitPanels,
},
@@ -82,6 +85,8 @@ export class CockpitCoordinator {
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
publish: this.publishCockpit,
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
onEntered: () => this.enterPanels(),
onExited: () => this.exitPanels(),
restoreTrackingFrame: (entity) => {
@@ -376,6 +381,10 @@ export class CockpitCoordinator {
this._layoutRightPanels();
this.cockpitView?.scheduleContextLayout();
},
publish: (slots) => this.publishCockpitPortal?.(slots),
afterRender: (callback) => {
void nextTick(callback);
},
});
}
+46 -26
View File
@@ -1,11 +1,25 @@
import { displayPanelScroller } from './displayPanelScroll.js';
/** Move the existing Display groups between their two presentation surfaces. */
/**
* 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 }) {
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;
@@ -17,11 +31,12 @@ export class CockpitDisplayPortal {
const slot = cockpitPanel?.querySelector(
`[data-cockpit-display-slot="${name}"]`,
);
if (!group || !slot || !group.parentNode) return [];
const anchor = document.createComment(`cockpit-display-home:${name}`);
group.before(anchor);
return [{ name, group, slot, anchor }];
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;
@@ -78,14 +93,7 @@ export class CockpitDisplayPortal {
const focusedElement = focusedRecord ? document.activeElement : null;
this.restoreOwner = nextActive ? 'cockpit' : 'standard';
this.active = nextActive;
for (const record of this.records) {
if (nextActive) record.slot.append(record.group);
else if (record.anchor.parentNode) record.anchor.after(record.group);
}
this.cockpitPanel?.classList.toggle(
'uses-shared-display-controls',
nextActive,
);
this.publish(nextActive ? this.slots : null);
const restoreScroll = () => {
if (nextActive && this.cockpitPanel)
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
@@ -94,19 +102,33 @@ export class CockpitDisplayPortal {
this.standardScrollTop;
};
if (!settle) {
restoreScroll();
this.restoreOwner = null;
this.afterRender(() => {
if (generation !== this.generation) return;
restoreScroll();
this.restoreOwner = null;
});
return;
}
this._frame(generation, () => {
restoreScroll();
focusedElement?.focus?.({ preventScroll: true });
// 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;
});
});
this.layout();
// The surfaces are measured once the groups have moved.
this.afterRender(() => {
if (!this.stopped && generation === this.generation) this.layout();
});
}
stop() {
@@ -121,14 +143,12 @@ export class CockpitDisplayPortal {
this.stop();
this.destroyed = true;
this.active = false;
for (const record of this.records) {
if (record.anchor.parentNode) record.anchor.after(record.group);
record.anchor.remove();
}
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
if (this.standardPanel)
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
this.publish(null);
const { standardPanel, standardScrollTop } = this;
this.afterRender(() => {
if (standardPanel)
displayPanelScroller(standardPanel).scrollTop = standardScrollTop;
});
this.records = [];
this.restoreOwner = null;
}
+57
View File
@@ -79,6 +79,14 @@ function fixture() {
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,
@@ -87,6 +95,7 @@ function fixture() {
layout: () => {
layouts += 1;
},
publish,
});
return {
standard,
@@ -96,6 +105,7 @@ function fixture() {
slot,
frames,
create,
published,
get focusCalls() {
return focusCalls;
},
@@ -120,6 +130,7 @@ test('Display groups return to their exact home with independent scroll position
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();
@@ -130,6 +141,7 @@ test('Display groups return to their exact home with independent scroll position
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 {
@@ -137,6 +149,51 @@ test('Display groups return to their exact home with independent scroll position
}
});
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 {
+2 -10
View File
@@ -8,16 +8,8 @@ export function onKeyDown(event) {
// 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;
if (
document
.getElementById('context-radio-dock')
?.classList.contains('disclosure-open')
)
return;
if (
document.querySelector('#cockpit-utility-controls [aria-expanded="true"]')
)
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();
+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]),
};
}
+131 -181
View File
@@ -24,8 +24,8 @@ import {
COCKPIT_CONTEXT_UPDATE_MS,
compassPointLabel,
formatCockpitCoordinate,
setCockpitRollingValue,
} 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();
@@ -43,138 +43,122 @@ const LOCALIZED_EMERGENCY = Object.freeze({
: 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);
if (this.callsign) {
this.callsign.textContent =
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
}
view.callsign =
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const speedKt = Number.isFinite(info.velocityMps)
? info.velocityMps * 1.94384
: null;
setCockpitRollingValue(this.speed, formatSpeedRulerTick(speedKt), speedKt, {
view.speed = {
text: formatSpeedRulerTick(speedKt),
value: speedKt,
immediate: forceContext,
});
if (this.speedRim)
this.speedRim.classList.toggle('unavailable', speedKt === null);
if (this.speedRimValue)
this.speedRimValue.textContent = formatSpeedRulerTick(speedKt);
const speedTicks = speedRulerTicks(speedKt, this.speedRimTicks.length);
this.speedRimTicks.forEach((element, index) => {
const tick = speedTicks[index];
element.hidden = !tick;
if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty(
'--curve',
altitudeRulerCurveInset(tick.slot).toFixed(5),
);
element.classList.toggle('major', tick.major);
const label = element.querySelector('b');
if (label) label.textContent = formatSpeedRulerTick(tick.valueKt);
});
};
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);
if (this.altitude) {
const displayedAltitudeFt = Number.isFinite(altitudeFt)
? Math.round(altitudeFt)
: null;
setCockpitRollingValue(
this.altitude,
const displayedAltitudeFt = Number.isFinite(altitudeFt)
? Math.round(altitudeFt)
: null;
view.altitude = {
text:
displayedAltitudeFt !== null
? formatNumber(displayedAltitudeFt)
: '-----',
displayedAltitudeFt,
{ immediate: forceContext },
);
}
if (this.altitudeRim)
this.altitudeRim.classList.toggle('unavailable', altitudeFt === null);
if (this.altitudeRimValue) {
this.altitudeRimValue.textContent = formatAltitudeRulerTick(altitudeFt);
}
const altitudeTicks = altitudeRulerTicks(
altitudeFt,
this.altitudeRimTicks.length,
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),
);
this.altitudeRimTicks.forEach((element, index) => {
const tick = altitudeTicks[index];
element.hidden = !tick;
if (!tick) return;
element.style.setProperty('--slot', tick.slot.toFixed(4));
element.style.setProperty('--depth', tick.depth.toFixed(4));
element.style.setProperty(
'--curve',
altitudeRulerCurveInset(tick.slot).toFixed(5),
);
element.classList.toggle('major', tick.major);
const label = element.querySelector('b');
if (label) label.textContent = formatAltitudeRulerTick(tick.valueFt);
});
setCockpitRollingValue(
this.headingValue,
String(Math.round(heading) % 360).padStart(3, '0'),
heading,
{ circularRange: 360, immediate: forceContext },
);
if (this.compassTape) {
const divisions = compassDivisions(heading);
const signature = divisions.join(',');
if (signature !== this.lastCompassSignature) {
this.lastCompassSignature = signature;
this.compassTape.innerHTML = divisions
.map((division, index) => {
const slot = index - 3;
return `<span class="${slot === 0 ? 'active' : ''}" style="--slot:${slot};--depth:${Math.abs(slot)}">${compassPointLabel(formatCompassDivision(division))}</span>`;
})
.join('');
}
}
if (this.clock)
this.clock.textContent = new Date().toISOString().slice(11, 19) + 'Z';
if (this.position) {
const lat = Number.isFinite(info.latitude)
? formatCockpitCoordinate(info.latitude, 'N', 'S')
: '--';
const lon = Number.isFinite(info.longitude)
? formatCockpitCoordinate(info.longitude, 'E', 'W')
: '--';
this.position.textContent = `${lat} · ${lon}`;
}
if (this.aircraftMeta) {
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,
);
this.aircraftMeta.textContent = emergency
? t('{aircraftClass} · {emergency} · {feedState}', {
aircraftClass,
emergency,
feedState,
})
: t('{aircraftClass} · {feedState} · COURSE ALIGNED', {
aircraftClass,
feedState,
});
this.aircraftMeta.classList.toggle(
'emergency',
info.emergency?.severity === 'emergency',
);
}
view.heading = {
text: String(Math.round(heading) % 360).padStart(3, '0'),
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 ||
@@ -189,24 +173,33 @@ export function updateHud(
this.maybeRefreshRegionalBrief(info);
this.updateContext(info, heading);
}
if (this.hud) this.hud.dataset.layer = info.layerId || 'flights';
}
/** 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');
if (this.routeFrom) this.routeFrom.textContent = routeLabel(origin);
if (this.routeTo) this.routeTo.textContent = routeLabel(destination);
if (this.routeStatus) {
this.routeStatus.textContent = validDestination
? t('ARROW · ESTIMATED DIRECTION')
: t('ROUTE DATA UNAVAILABLE');
}
if (this.route) this.route.hidden = !origin && !destination;
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) ||
@@ -229,68 +222,24 @@ export function updateRoute(info) {
this.clearPredictiveRoute();
return;
}
if (this.routeDirection) {
const displayedRelative = Math.max(-120, Math.min(120, relative));
this.routeDirection.hidden = false;
this.routeDirection.style.setProperty(
'--route-angle',
`${displayedRelative.toFixed(2)}deg`,
);
}
if (this.routeDirectionLabel) {
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
bearing: `${formatHeadingReadout(destinationBearing)}°`,
});
}
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) {
if (!this.weatherToggle) return;
const active = !!enabled;
this.weatherToggle.setAttribute('aria-pressed', String(active));
const weatherLabel = active
? t('Disable cockpit weather effects')
: t('Enable cockpit weather effects');
this.weatherToggle.setAttribute('aria-label', weatherLabel);
this.weatherToggle.title = weatherLabel;
if (this.weatherState) {
this.weatherState.textContent = active ? t('ON') : t('OFF');
}
this.contextView.weather = !!enabled;
this.publishContextView();
}
export function setVisionMode(mode, { revealParameters = false } = {}) {
const next = normalizeCockpitVisionMode(mode);
this.visionMode = next;
const labels = {
optical: t('NORMAL'),
crt: t('CRT'),
nvg: t('NVG'),
thermal: t('FLIR'),
anime: t('ANIME'),
noir: t('NOIR'),
};
const names = {
optical: t('Normal'),
crt: t('CRT'),
nvg: t('Night vision'),
thermal: t('Thermal'),
anime: t('Anime'),
noir: t('Noir'),
};
if (this.visionCurrent) {
this.visionCurrent.dataset.cockpitVision = next;
this.visionCurrent.setAttribute(
'aria-label',
t('Current cockpit vision style: {style}. Activate for next style.', {
style: names[next],
}),
);
this.visionCurrent.title = t('Current style: {style} — click for next', {
style: names[next],
});
}
if (this.visionCurrentLabel)
this.visionCurrentLabel.textContent = labels[next];
this.publishVision(createVisionState(next));
this.onVisionChange?.(next, this.active, { revealParameters });
}
@@ -303,5 +252,6 @@ export function cycleVisionMode(direction = 1) {
}
export function clearPredictiveRoute() {
if (this.routeDirection) this.routeDirection.hidden = true;
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'),
/^Текущий/,
);
});
+53 -92
View File
@@ -1,5 +1,4 @@
/** Measure Cockpit panels against visible HUD and viewport obstacles. */
import { t, msg } from '../i18n/index.js';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
@@ -16,8 +15,12 @@ import {
export function scheduleContextLayout() {
if (this.destroyed) return false;
const contextVisible = this.context && !this.context.hidden;
const signalVisible = this.signalStream && !this.signalStream.hidden;
// 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(() => {
@@ -31,20 +34,10 @@ export function scheduleContextLayout() {
export function setContextCollapsed(collapsed) {
const wasCollapsed = this.contextCollapsed;
this.contextCollapsed = Boolean(collapsed);
if (this.context)
this.context.dataset.collapsed = String(this.contextCollapsed);
if (this.contextToggle) {
const expanded = !this.contextCollapsed;
this.contextToggle.setAttribute('aria-expanded', String(expanded));
this.contextToggle.setAttribute(
'aria-label',
expanded ? t('Collapse Contact panel') : t('Expand Contact panel'),
);
this.contextToggle.title = t(
expanded ? msg('Collapse contact panel') : msg('Expand contact panel'),
);
const icon = this.contextToggle.querySelector('.material-symbols-outlined');
if (icon) icon.textContent = expanded ? 'chevron_left' : 'chevron_right';
// 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'));
@@ -56,25 +49,10 @@ export function setSignalCollapsed(collapsed, { user = false } = {}) {
const wasCollapsed = this.signalCollapsed;
this.signalCollapsed = Boolean(collapsed);
if (user) this.signalUserCollapsed = this.signalCollapsed;
if (this.signalStream)
this.signalStream.dataset.collapsed = String(this.signalCollapsed);
if (this.signalToggle) {
const expanded = !this.signalCollapsed;
this.signalToggle.setAttribute('aria-expanded', String(expanded));
this.signalToggle.setAttribute(
'aria-label',
t(
expanded
? msg('Collapse cockpit briefing panel')
: msg('Expand cockpit briefing panel'),
),
);
this.signalToggle.title = t(
expanded ? msg('Collapse briefing panel') : msg('Expand briefing panel'),
);
const icon = this.signalToggle.querySelector('.material-symbols-outlined');
if (icon)
icon.textContent = expanded ? 'right_panel_close' : 'right_panel_open';
// 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 });
@@ -155,20 +133,8 @@ export function syncSignalLayout() {
const utilityControls = document.getElementById('cockpit-utility-controls');
if (window.matchMedia('(max-width: 760px)').matches) {
this.signalStream.dataset.layoutMode = 'compact-top';
utilityControls?.classList.remove('layout-primary-only');
utilityControls
?.querySelectorAll('.cockpit-utility-control')
.forEach((control) => {
const hiddenSibling = Boolean(
utilityControls.querySelector(
'.cockpit-utility-control.is-expanded',
) && !control.classList.contains('is-expanded'),
);
control.setAttribute('aria-hidden', String(hiddenSibling));
});
this.hud?.style.removeProperty('--cockpit-utility-top');
this.hud?.style.removeProperty('--cockpit-utility-max-height');
this.hud?.style.removeProperty('--cockpit-utility-expanded-max-height');
// 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');
@@ -253,22 +219,13 @@ export function syncSignalLayout() {
'--cyber-cockpit-panel-height',
`${lane.panelHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-top',
`${alignedTop.toFixed(2)}px`,
this.publishUtilityLayout(
utilityLayoutView({
top: `${alignedTop.toFixed(2)}px`,
maxHeight: `${lane.utilityHeight.toFixed(2)}px`,
expandedMaxHeight: `${lane.panelHeight.toFixed(2)}px`,
}),
);
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${lane.utilityHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height',
`${lane.panelHeight.toFixed(2)}px`,
);
utilityControls.classList.remove('layout-primary-only');
utilityControls
.querySelectorAll('.cockpit-utility-control')
.forEach((control) => control.setAttribute('aria-hidden', 'false'));
if (expandedControl) {
this.signalStream.style.setProperty(
'--cockpit-signal-top',
@@ -311,14 +268,6 @@ export function syncSignalLayout() {
: 0,
});
const availableHeight = utilityAnchor.maxHeight;
this.hud?.style.setProperty(
'--cockpit-utility-top',
`${utilityAnchor.top.toFixed(1)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${availableHeight.toFixed(2)}px`,
);
const utilityLayout =
expandedControl && collapsedControl
? resolveCockpitUtilityLayout({
@@ -327,25 +276,37 @@ export function syncSignalLayout() {
collapsedHeight,
})
: { primaryOnly: false, expandedMaxHeight: availableHeight };
utilityControls?.classList.toggle(
'layout-primary-only',
utilityLayout.primaryOnly,
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,
}),
);
this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height',
`${utilityLayout.expandedMaxHeight.toFixed(2)}px`,
);
utilityControls
?.querySelectorAll('.cockpit-utility-control')
.forEach((control) => {
const hiddenSibling =
utilityLayout.primaryOnly && control === collapsedControl;
control.setAttribute('aria-hidden', String(hiddenSibling));
if (hiddenSibling && control.contains(document.activeElement)) {
expandedControl
?.querySelector('.cockpit-utility-glyph')
?.focus({ preventScroll: true });
}
});
// 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']);
});
-132
View File
@@ -34,39 +34,10 @@ export const COCKPIT_GROUND_WAIT_TIMEOUT_MS = 5000;
export const COCKPIT_BRIEF_ROTATE_MS = 9000;
export const COCKPIT_BRIEF_CYCLE_OFF_HELP =
'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.';
export const COCKPIT_BRIEF_CYCLE_ON_HELP =
'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.';
export const COCKPIT_REGIONAL_REFRESH_MS = 5 * 60_000;
export const COCKPIT_REGIONAL_REFRESH_DISTANCE_M = 25_000;
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',
},
];
export function isRenderedOnScreen(element) {
if (!element) return false;
for (let node = element; node instanceof Element; node = node.parentElement) {
@@ -169,106 +140,3 @@ export function formatCockpitWindDirection(value) {
const degrees = Math.round(normalized) % 360;
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
// 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';
}
export function setCockpitRollingValue(
element,
text,
numericValue,
{ circularRange = null, immediate = false } = {},
) {
if (!element) return;
const nextText = String(text);
const previousText = element.dataset.rollingText;
const previousValue = Number(element.dataset.rollingValue);
const nowMs = performance.now();
const lastRollMs = Number(element.dataset.rollingAt);
if (
!immediate &&
previousText !== undefined &&
previousText !== nextText &&
Number.isFinite(lastRollMs) &&
nowMs - lastRollMs < 220
) {
return;
}
element.dataset.rollingText = nextText;
element.dataset.rollingAt = String(nowMs);
if (Number.isFinite(numericValue))
element.dataset.rollingValue = String(numericValue);
else delete element.dataset.rollingValue;
element.setAttribute('aria-label', nextText);
if (immediate || previousText === undefined || previousText === nextText) {
if (
previousText !== nextText ||
!element.querySelector('.cockpit-roll-token')
) {
element.replaceChildren(
...Array.from(nextText, (character) => {
const token = document.createElement('span');
token.className = rollTokenClass(character);
token.setAttribute('aria-hidden', 'true');
token.textContent = character;
return token;
}),
);
}
return;
}
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, ' ');
const fragment = document.createDocumentFragment();
for (let index = 0; index < width; index += 1) {
const previousCharacter = from[index];
const nextCharacter = to[index];
const token = document.createElement('span');
token.className = rollTokenClass(nextCharacter);
token.setAttribute('aria-hidden', 'true');
if (
previousCharacter === nextCharacter ||
!/\d/.test(previousCharacter) ||
!/\d/.test(nextCharacter)
) {
token.textContent = nextCharacter === ' ' ? '\u00a0' : nextCharacter;
fragment.append(token);
continue;
}
token.classList.add('is-rolling', `roll-${direction}`);
const track = document.createElement('span');
track.className = 'cockpit-roll-track';
const first = document.createElement('span');
const second = document.createElement('span');
first.textContent = direction === 'up' ? previousCharacter : nextCharacter;
second.textContent = direction === 'up' ? nextCharacter : previousCharacter;
track.append(first, second);
token.append(track);
fragment.append(token);
}
element.replaceChildren(fragment);
}
+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');
});
+43 -100
View File
@@ -1,112 +1,55 @@
/** Reconcile actionable signal rows without disturbing native keyboard focus. */
/** 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';
export function renderCockpitSignals() {
if (!this.signalList) return;
const existing = [...this.signalList.children];
const focusedEntry = existing.find((entry) =>
entry.contains(document.activeElement),
/**
* 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 }),
},
};
}),
);
const entriesByKey = new Map();
for (const entry of existing) {
const key = entry.dataset.signalKey;
if (!entriesByKey.has(key)) entriesByKey.set(key, []);
entriesByKey.get(key).push(entry);
}
const setText = (element, value) => {
if (element.textContent !== value) element.textContent = value;
};
const entries = this.signalItems.map((item) => {
// Identity includes the action target: a reused status key must never
// silently turn a focused flight button into a different selection.
const key = JSON.stringify([
item.key,
Boolean(item.target),
item.target?.layerId || '',
String(item.target?.id ?? ''),
]);
let entry = entriesByKey.get(key)?.shift();
if (!entry) {
entry = document.createElement('li');
entry.dataset.signalKey = key;
const time = document.createElement('time');
const body = document.createElement('div');
const heading = document.createElement(item.target ? 'button' : 'strong');
if (item.target) {
heading.type = 'button';
heading.className = 'cockpit-signal-target';
const label = document.createElement('span');
label.className = 'cockpit-signal-target-label';
const rule = document.createElement('span');
rule.className = 'cockpit-signal-target-rule';
rule.setAttribute('aria-hidden', 'true');
const chevron = document.createElement('span');
chevron.className =
'material-symbols-outlined cockpit-signal-target-chevron';
chevron.setAttribute('aria-hidden', 'true');
chevron.textContent = 'chevron_right';
heading.append(label, rule, chevron);
}
body.append(heading, document.createElement('span'));
entry.append(time, body);
}
const [time, body] = entry.children;
const [heading, copy] = body.children;
const className = item.target ? `${item.tone} actionable` : item.tone;
if (entry.className !== className) entry.className = className;
setText(time, new Date(item.timestamp).toISOString().slice(11, 19) + 'Z');
if (item.target) {
heading.dataset.signalLayer = item.target.layerId;
heading.dataset.signalId = item.target.id;
const label = t('Select flight {title}', { title: item.title });
if (heading.getAttribute('aria-label') !== label)
heading.setAttribute('aria-label', label);
setText(heading.children[0], item.title);
} else {
setText(heading, item.title);
}
setText(copy, item.detail);
return entry;
});
const retainedFocus = entries.includes(focusedEntry) ? focusedEntry : null;
if (focusedEntry && !retainedFocus) {
// A departed contact cannot remain selectable. Continue from the stable
// briefing footer instead of dropping keyboard traversal to the page top.
(this.briefTabs?.[this.briefPageIndex] || this.signalToggle)?.focus({
preventScroll: true,
});
}
for (const entry of existing) if (!entries.includes(entry)) entry.remove();
// Ordinary insertBefore moves disconnect an element and lose its focus.
// Reorder the other rows around the focused row, which stays connected.
const focusIndex = retainedFocus ? entries.indexOf(retainedFocus) : -1;
if (retainedFocus) {
let anchor = retainedFocus;
for (let index = focusIndex - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.nextElementSibling !== anchor)
this.signalList.insertBefore(entry, anchor);
anchor = entry;
}
}
let anchor = retainedFocus
? retainedFocus.nextElementSibling
: this.signalList.firstElementChild;
for (let index = focusIndex + 1; index < entries.length; index += 1) {
const entry = entries[index];
if (entry === anchor) anchor = anchor.nextElementSibling;
else this.signalList.insertBefore(entry, anchor);
}
this.scheduleContextLayout();
}
export function pushCockpitSignal(key, tone, title, detail, target = null) {
if (!this.signalList || !title || !detail) return;
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,
@@ -116,7 +59,7 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
timestamp: Date.now(),
});
this.signalItems = this.signalItems.slice(0, 5);
this.renderCockpitSignals();
this.publishCockpitSignals();
}
export function updateCockpitSignals(snapshot, unknownCount) {
@@ -195,7 +138,7 @@ export function updateCockpitSignals(snapshot, unknownCount) {
}
this.signalItems = nextItems;
this.signalSignatures.clear();
this.renderCockpitSignals();
this.publishCockpitSignals();
}
export function handleSignalClick(event) {
+49 -40
View File
@@ -49,18 +49,27 @@ export function toggleTrackedTr3b() {
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) {
if (!this.tr3bToggle) return;
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.tr3bToggle.hidden = !icao24;
this.tr3bToggle.setAttribute('aria-pressed', converted ? 'true' : 'false');
this.tr3bToggle.title = converted
? t('Restore real aircraft')
: t('Reclassify as TR-3B');
this.presence.tr3b = icao24 ? { converted } : null;
this.publishPresenceView();
}
export function syncEntry() {
@@ -70,14 +79,12 @@ export function syncEntry() {
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
this.syncTr3bToggle(trackedContact ? info : null);
const available = !!(this.isEntryAllowed() && trackedContact);
// Change-only DOM writes: this runs on a preUpdate cadence, and
// unconditional `hidden` assignments invalidate style/layout every frame
// even when nothing changed. (perf item 9)
if (this._entryAvailable === available) return;
this._entryAvailable = available;
if (this.entry) this.entry.hidden = !available;
if (this.mapViewButton) this.mapViewButton.hidden = true;
if (this.resetGlobeButton) this.resetGlobeButton.hidden = true;
// 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 = {}) {
@@ -154,7 +161,6 @@ export function enter() {
this.surfaceWaitStartedMs = performance.now();
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.lastCompassSignature = '';
this.cockpitAnchorValid = false;
this.lastCameraUpdateMs = 0;
this.active = true;
@@ -165,20 +171,15 @@ export function enter() {
this.viewer.trackedEntity = undefined;
this.viewer.scene.screenSpaceCameraController.enableInputs = false;
document.body.classList.add('cockpit-mode');
// Activation writes entry/quick/map visibility directly, bypassing
// syncEntry's change-only cache — invalidate it so the exit-path
// syncEntry re-applies every write (notably re-hiding mapViewButton).
this._entryAvailable = undefined;
if (this.entry) this.entry.hidden = true;
if (this.tr3bToggle) {
this.tr3bToggle.hidden = true;
this._tr3bSignature = null;
}
if (this.mapViewButton) this.mapViewButton.hidden = false;
if (this.resetGlobeButton) this.resetGlobeButton.hidden = false;
if (this.hud) this.hud.hidden = false;
if (this.signalStream) this.signalStream.hidden = false;
this.hud?.classList.add('signals-active');
// 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);
@@ -200,7 +201,11 @@ export function enter() {
this.updateHud(info, performance.now(), true);
this.setVisionMode(this.getInitialVisionMode());
this.scheduleContextLayout();
this.mapViewButton?.focus({ preventScroll: true });
// 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;
@@ -220,7 +225,6 @@ export function exit({ restoreTracking = true } = {}) {
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.contextNavigationDeadlineMs = 0;
this.lastCompassSignature = '';
this.stopBriefRotation();
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
@@ -228,14 +232,12 @@ export function exit({ restoreTracking = true } = {}) {
this.regionalBriefSubjectId = null;
document.body.classList.remove('cockpit-mode');
this.onExited?.();
this.hud?.style.removeProperty('--cockpit-utility-top');
this.hud?.style.removeProperty('--cockpit-utility-max-height');
if (this.hud) this.hud.hidden = true;
if (this.route) this.route.hidden = true;
this.publishUtilityLayout(null);
this.presence.active = false;
this.publishPresenceView();
this.routeView.visible = false;
this.clearPredictiveRoute();
this.setVisionMode('optical');
if (this.signalStream) this.signalStream.hidden = true;
this.hud?.classList.remove('signals-active');
this.viewer.scene.screenSpaceCameraController.enableInputs = true;
if (entity && this.viewer.entities.contains(entity))
entity.show = this.trackedEntityWasShown;
@@ -251,8 +253,15 @@ export function exit({ restoreTracking = true } = {}) {
? this.entry
: this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null;
if (!this.destroyed && restoreTarget?.isConnected && !restoreTarget.hidden) {
restoreTarget.focus({ preventScroll: true });
}
// 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);
});
+4
View File
@@ -27,8 +27,10 @@ import {
initTrackedReadout,
} from '../data/trackedReadout.js';
import {
activateWorldOverlayAction,
destroyWorldOverlay,
initWorldOverlay,
subscribeWorldOverlayActions,
} from '../overlays/worldOverlay.js';
import {
destroyDetection,
@@ -88,6 +90,8 @@ export class StyleManager extends ApplicationShell {
initTrackedReadout,
destroyWorldOverlay,
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
destroyDetection,
initDetection,
cycleDetectionMode,
+5 -6
View File
@@ -86,10 +86,9 @@ export function _initGlobalContextPanel() {
!this._dataManager?.layers?.has('military-installations')
)
return;
const button = this._installationsSearchBtn;
if (button.getAttribute('aria-busy') === 'true') return;
button.setAttribute('aria-disabled', 'true');
button.setAttribute('aria-busy', 'true');
if (this._installationsSearchBusy) return;
this._installationsSearchBusy = true;
this._presentContext();
void this._runUserFacingContextAction(async (notificationToken) => {
const enabled = await this._dataManager.setEnabled(
'military-installations',
@@ -118,8 +117,8 @@ export function _initGlobalContextPanel() {
}, t('Nearby installations could not be refreshed; try again')).finally(
() => {
if (this.destroyed) return;
button.setAttribute('aria-disabled', 'false');
button.setAttribute('aria-busy', 'false');
this._installationsSearchBusy = false;
this._presentContext();
},
);
});
+15 -3
View File
@@ -1,5 +1,8 @@
import { _initGlobalContextPanel } from './contextBindings.js';
import { _syncContextModeButtons } from './contextPresentation.js';
import {
_syncContextModeButtons,
_presentContext,
} from './contextPresentation.js';
import {
_trackContextLayerReaction,
_waitForContextLayerSettlement,
@@ -22,12 +25,18 @@ import {
} from './contextActions.js';
import { connectContextManager } from './contextSubscriptions.js';
/** Own Context transactions and controls through explicit layer and presentation ports. */
/**
* 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 }) {
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;
@@ -93,6 +102,9 @@ export class ContextControls {
_syncContextModeButtons(...args) {
return _syncContextModeButtons.call(this, ...args);
}
_presentContext(...args) {
return _presentContext.call(this, ...args);
}
_trackContextLayerReaction(...args) {
return _trackContextLayerReaction.call(this, ...args);
}
+8 -7
View File
@@ -17,7 +17,9 @@ class Button extends EventTarget {
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: {
@@ -35,7 +37,7 @@ function fixture(t, elements = {}) {
controls.stop();
controls.disconnect();
});
return { controls, calls };
return { controls, calls, views };
}
test('Context starts with an explicit idle snapshot', (t) => {
@@ -70,7 +72,9 @@ test('a pending Context tab cannot reopen a disposed panel', async (t) => {
test('stopping during installations enable prevents the delayed search', async (t) => {
const button = new Button();
const { controls, calls } = fixture(t, { _installationsSearchBtn: button });
const { controls, calls, views } = fixture(t, {
_installationsSearchBtn: button,
});
let release;
controls._dataManager = {
layers: new Map([['military-installations', {}]]),
@@ -87,11 +91,8 @@ test('stopping during installations enable prevents the delayed search', async (
release(true);
await turn();
assert.equal(calls.length, 0);
assert.equal(
button.getAttribute('aria-busy'),
'true',
'disposed controls are not mutated by completion',
);
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) => {
+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');
});
+21 -36
View File
@@ -1,44 +1,29 @@
export function _syncContextModeButtons() {
if (this.destroyed) return;
const flightsActive = this._contextMode === 'flights';
const missionsActive = this._contextMode === 'space-missions';
const panel = this._globalContextPanel;
panel?.classList.toggle('context-enabled', flightsActive || missionsActive);
panel?.setAttribute('data-context-mode', this._contextMode || 'none');
this._globalContextFlightsBtn?.classList.toggle('active', flightsActive);
this._globalContextFlightsBtn?.setAttribute(
'aria-selected',
String(flightsActive),
);
this._globalContextMissionsBtn?.classList.toggle('active', missionsActive);
this._globalContextMissionsBtn?.setAttribute(
'aria-selected',
String(missionsActive),
);
const transitionBusy = Boolean(this._contextModeChanging);
// Both Context choices stay in the ordinary Tab sequence. 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.
for (const button of [
this._globalContextFlightsBtn,
this._globalContextMissionsBtn,
]) {
if (!button) continue;
button.disabled = false;
button.tabIndex = 0;
button.setAttribute('aria-disabled', String(transitionBusy));
button.setAttribute('aria-busy', String(transitionBusy));
}
if (this._contextModeStandby)
this._contextModeStandby.hidden = flightsActive || missionsActive;
if (this._contextFlightsView)
this._contextFlightsView.hidden = !flightsActive;
if (this._contextMissionsView)
this._contextMissionsView.hidden = !missionsActive;
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,
});
}
-87
View File
@@ -1,87 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
function fixture(theme = 'cyber') {
const nodes = new Map();
const node = (id, collapsed = true) => {
const classes = new Set(collapsed ? ['collapsed'] : []);
const value = { id, hidden: false, children: [],
matches: () => true,
contains(target) { return this === target || this.children.some((child) => child.contains(target)); },
classList: {
contains: (name) => classes.has(name),
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle: (name, on) => on ? classes.add(name) : classes.delete(name),
},
};
nodes.set(id, value);
return value;
};
const rail = node('rail');
const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel');
rail.children = [display, context, cctv];
for (const panel of rail.children) panel.parentElement = rail;
context.children = [radio];
radio.parentElement = context;
const saved = [], claimed = [];
const owner = {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
};
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner);
return { display, context, cctv, radio, owner, saved, claimed,
doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) },
};
}
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
f.cctv.classList.add('cyber-accordion-collapsed');
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
} finally { globalThis.document = prior; }
});
test('opening already-expanded Radio reveals Context and closes Display', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.radio.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
assert.equal(f.context.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), true);
} finally { globalThis.document = prior; }
});
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
const f = fixture(theme), prior = globalThis.document;
globalThis.document = f.doc;
try {
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
f.owner.setPanelCollapsed('pp-toggles', false, options);
f.owner.setPanelCollapsed('cctv-panel', false, options);
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.cctv.classList.contains('collapsed'), false);
if (theme === 'cyber') assert.deepEqual(f.saved, []);
} finally { globalThis.document = prior; }
}
});
+16 -4
View File
@@ -13,14 +13,23 @@ const SETTING_FIELDS = Object.freeze({
sectorDeg: ['sector', 8, 60],
});
/** Read configuration separately from whether map effects can currently run. */
export function getCyberSonarControlState(doc = globalThis.document) {
/**
* 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),
@@ -47,7 +56,7 @@ export function setCyberSonarControls(
const fail = (error) => ({
ok: false,
error,
sonar: getCyberSonarControlState(doc),
sonar: getCyberSonarControlState(doc, { hudActive: owner?.hud?.visible }),
});
if (!request || typeof request !== 'object' || Array.isArray(request)) {
return fail('Provide Cyber sonar settings as an object.');
@@ -82,5 +91,8 @@ export function setCyberSonarControls(
if (Object.hasOwn(request, 'enabled')) {
owner._setCyberSonarEnabled(request.enabled);
}
return { ok: true, sonar: getCyberSonarControlState(doc) };
return {
ok: true,
sonar: getCyberSonarControlState(doc, { hudActive: owner?.hud?.visible }),
};
}
+20 -2
View File
@@ -48,6 +48,8 @@ test('real runner and facade use visual settings, preserve visibility and return
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 });
@@ -82,7 +84,7 @@ test('real runner and facade use visual settings, preserve visibility and return
});
test('native sonar sliders represent every accepted integer without browser snapping', () => {
const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8');
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"`));
}
@@ -132,7 +134,7 @@ test('rejects the whole invalid request before any mutation', () => {
});
test('requires Cyber and preserves off state and every omitted setting', () => {
for (const theme of ['tactical', 'operator', 'minimal']) {
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, []);
@@ -159,3 +161,19 @@ test('reports inactive map effects in Cockpit, hidden HUD, unsupported and unini
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);
});
+86 -66
View File
@@ -12,20 +12,27 @@ import {
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { StyleManager } from './applicationShell.js';
import { bindDisplayControls } from './displayControls.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 ['layer-panels.html', 'context.html']) {
for (const file of ['panels/CctvPanel.vue', 'ContextView.vue']) {
const markup = readFileSync(
new URL(`./templates/${file}`, import.meta.url),
new URL(`./views/${file}`, import.meta.url),
'utf8',
);
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
// 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(
markup.indexOf('class="panel-header"') <
markup.indexOf('class="cyber-panel-body"'),
// 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(
@@ -54,8 +61,9 @@ function styleOwner(t, initialVariant = 'cyber') {
const hud = {
getVariant: () => variant,
setVariant: (value) => {
variant = value;
applyHudUiTheme(root, 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]),
@@ -64,6 +72,7 @@ function styleOwner(t, initialVariant = 'cyber') {
const owner = Object.create(VisualSettings.prototype);
Object.assign(owner, {
activeStyle: 'normal',
store: { publish() {} },
readHud: () => hud,
readShareLinks: () => ({ onStyleChange() {} }),
_visualEffects: {
@@ -75,8 +84,6 @@ function styleOwner(t, initialVariant = 'cyber') {
),
},
services: { governorRequestRender() {}, setDetectionStyle() {} },
_hudLayoutSelect: { value: initialVariant },
_styleIndicator: {},
_setBloomEnabled: (value) => calls.push(['bloom', value]),
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
_applyDetectionPreset: (value) => calls.push(['detection', value]),
@@ -85,14 +92,17 @@ function styleOwner(t, initialVariant = 'cyber') {
for (const name of [
'setCelestialRingEnabled',
'_startTransition',
'_updateStyleMiniStatus',
'_publishActiveStyle',
'_updateSliderPanel',
'_updateHudButtonState',
'_syncIrBoost',
'_syncCockpitInheritedStyle',
'_syncCyberSonarControl',
'_scheduleAdaptivePanelLayout',
'_applyDetectionFadeFromUi',
'_setDetectionFade',
// Intensities: the fixture has no post-process stages to drive.
'_setBloomIntensity',
'_setSharpenIntensity',
])
owner[name] = () => {};
return { owner, hud, root, calls };
@@ -112,7 +122,6 @@ test('visual styles retain Cyber through real preset application without droppin
assert.equal(owner.activeStyle, style);
assert.equal(hud.getVariant(), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(owner._hudLayoutSelect.value, 'cyber');
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
}
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
@@ -130,7 +139,7 @@ test('visual styles retain Cyber through real preset application without droppin
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
const { owner, hud, root } = styleOwner(t);
for (const variant of ['tactical', 'operator', 'minimal']) {
for (const variant of ['tactical', 'minimal']) {
owner._setHudVariant(variant);
assert.equal(hud.getVariant(), variant);
assert.equal(root.dataset.uiTheme, undefined);
@@ -149,8 +158,8 @@ test('leaving Cyber without a preset choice restores the prior visual preset', (
assert.equal(owner._preCyberStyle, 'noir');
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
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);
@@ -169,8 +178,8 @@ test('a preset explicitly selected in Cyber survives exit and resets for re-entr
// Reselecting the active mode must not erase the user's preset intent.
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner._preCyberStyle, 'noir');
owner._setHudVariant('operator', { applyVisualDefaults: true });
assert.equal(hud.getVariant(), 'operator');
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);
@@ -191,7 +200,7 @@ test('an explicit same-value FLIR choice is preserved on Cyber exit', (t) => {
owner.setStyle('thermal', { userInitiated: true });
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner._cyberStyleUserSelected, true);
owner._setHudVariant('operator', { applyVisualDefaults: true });
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'thermal');
});
@@ -206,7 +215,7 @@ test('an authoritative same-Cyber reset clears local preset ownership', (t) => {
assert.equal(owner.activeStyle, 'anime');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'anime');
});
@@ -223,28 +232,22 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
};
const display = makeOwner();
const hudLayout = new EventTarget();
hudLayout.value = 'cyber';
const animeButton = new EventTarget();
animeButton.dataset = { style: 'anime' };
const controls = bindDisplayControls({
elements: { hudLayout, styleButtons: [animeButton] },
actions: {
setHudLayout: (value) => display.owner.setHudLayout(value),
setStyle: (value) =>
display.owner.setStyle(value, { userInitiated: true }),
},
// 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 }),
});
hudLayout.dispatchEvent(new Event('change'));
store.act('setHudLayout', 'cyber');
assert.equal(display.hud.getVariant(), 'cyber');
assert.equal(display.owner.activeStyle, 'thermal');
animeButton.dispatchEvent(new Event('click'));
store.act('setStyle', 'anime');
assert.equal(display.owner.activeStyle, 'anime');
hudLayout.value = 'operator';
hudLayout.dispatchEvent(new Event('change'));
assert.equal(display.hud.getVariant(), 'operator');
store.act('setHudLayout', 'minimal');
assert.equal(display.hud.getVariant(), 'minimal');
assert.equal(display.owner.activeStyle, 'anime');
controls.destroy();
store.detach(token);
const voice = makeOwner();
const eventSource = { addEventListener() {}, removeEventListener() {} };
@@ -265,9 +268,9 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
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: 'operator' });
const exited = await runVoiceAction('set_hud', { layout: 'minimal' });
assert.equal(exited.ok, true);
assert.equal(voice.hud.getVariant(), 'operator');
assert.equal(voice.hud.getVariant(), 'minimal');
assert.equal(voice.owner.activeStyle, 'anime');
});
@@ -276,28 +279,23 @@ test('scope choice and expanded feather survive every HUD layout and both restor
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
owner._scopeBtn = { checked: true };
owner._scopeFeatherSlider = { value: '11', disabled: false };
owner._scopeFeatherValue = {};
let shown = null;
owner.store = { publish: (view) => (shown = view.scope) };
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
assert.equal(owner._scopeBtn.checked, false);
assert.equal(owner._scopeFeatherSlider.disabled, true);
assert.equal(owner._scopeFeatherSlider.value, '200');
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
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(owner._scopeBtn.checked, false, 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.equal(owner._scopeBtn.checked, true);
assert.equal(owner._scopeFeatherSlider.disabled, false);
assert.equal(owner._scopeFeatherValue.textContent, '175%');
assert.deepEqual(shown, { enabled: true, featherPct: 175 });
await owner.applyVisualState({ scope: { featherPct: 500 } });
assert.equal(getScopeMaskFeather(), 2);
assert.equal(owner._scopeFeatherSlider.value, '200');
assert.deepEqual(shown, { enabled: true, featherPct: 200 });
});
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
@@ -319,7 +317,7 @@ test('scene and share HUD transitions do not restore a local pre-Cyber preset',
assert.equal(owner.activeStyle, 'retro');
assert.equal(owner._preCyberStyle, null);
assert.equal(owner._cyberStyleUserSelected, false);
owner._setHudVariant('operator', { applyVisualDefaults: true });
owner._setHudVariant('minimal', { applyVisualDefaults: true });
assert.equal(owner.activeStyle, 'retro');
owner.setStyle('surveillance');
@@ -348,7 +346,8 @@ test('scene and share restoration override Cyber and preserve restored Cyber par
style: 'surveillance',
hudVariant: 'operator',
});
assert.equal(hud.getVariant(), 'operator');
// A link saved with the retired Operator layout restores as Tactical.
assert.equal(hud.getVariant(), 'tactical');
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
@@ -570,8 +569,9 @@ test('Cyber side panels share one width and one framed surface material', () =>
});
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'minimal', 'cyber']);
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
assert.equal(normalizeHudLayout('operator'), 'tactical');
assert.equal(normalizeHudLayout('unknown'), 'tactical');
});
@@ -603,7 +603,7 @@ test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', ()
);
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
cyberVisualDefaultsForHudTransition('cyber', 'minimal', {
explicit: true,
}),
null,
@@ -644,20 +644,21 @@ test('Cyber visual defaults update the style, thermal palette and render state',
});
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
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" data-i18n>Cyber<\/option>/);
assert.match(display, /<option value="cyber">\{\{ t\('Cyber'\) \}\}<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
/id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/,
/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);
@@ -677,7 +678,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
/\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/,
);
assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/);
assert.match(hud, /class="hud-sonar" aria-hidden="true"/);
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/);
@@ -720,10 +721,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /\.hud-top-left/);
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-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,
@@ -768,11 +766,33 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
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: 54px;/);
assert.match(cyberStyles, /right: 62px;/);
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'/);
@@ -867,7 +887,7 @@ test('Cyber panels retain clean edges without the added footer band', () => {
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
const template = readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
new URL('./views/panels/DisplayPanel.vue', import.meta.url),
'utf8',
);
assert.match(template, /id="cyber-sonar-toggle"/);
+99 -151
View File
@@ -1,8 +1,7 @@
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
import { createFrameRateMonitor } from './frameRateMonitor.js';
import { bindApplicationShortcuts } from './visualInput.js';
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
import { canonicalizeDensity } from '../data/detectionPolicy.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 {
@@ -13,10 +12,11 @@ export class DisplayBindings {
operations,
readState,
claimDetection,
displayStore,
}) {
Object.assign(
this,
{ viewer, services, readState, claimDetection },
{ viewer, services, readState, claimDetection, displayStore },
elements,
operations,
);
@@ -42,19 +42,8 @@ export class DisplayBindings {
get _models3dEnabled() {
return this.readState()._models3dEnabled;
}
get _models3dModeBtns() {
return this.readState()._models3dModeBtns;
}
get _detectionAllocationBtns() {
return this.readState()._detectionAllocationBtns;
}
_initUI() {
const {
cycleDetectionMode,
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
} = this.services;
const { cycleDetectionMode, isScopeMaskEnabled } = this.services;
this._applicationShortcuts?.destroy();
this._frameRateMonitor?.destroy();
this._frameRateMonitor = createFrameRateMonitor({
@@ -66,13 +55,7 @@ export class DisplayBindings {
searchInput: this._locationSearch,
actions: {
setStyle: (style) => this.setStyle(style, { userInitiated: true }),
dismissSearch: () => {
if (this._locationSearch.classList.contains('expanded')) {
this._locationSearch.classList.remove('expanded');
this._locationSearch.value = '';
this._locationSearch.blur();
}
},
dismissSearch: () => this._closeLocationSearch?.({ ifOpen: true }),
toggleHud: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.hud.toggle();
@@ -93,142 +76,107 @@ export class DisplayBindings {
},
});
syncScopeControls(
this._scopeBtn,
this._scopeFeatherSlider,
isScopeMaskEnabled(),
);
this._displayControls?.destroy();
this._displayControls = bindDisplayControls({
elements: {
styleButtons: document.querySelectorAll('.style-btn'),
bloomButton: this._bloomBtn,
bloomSlider: this._bloomSlider,
sharpenButton: this._sharpenBtn,
sharpenSlider: this._sharpenSlider,
scopeToggle: this._scopeBtn,
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
sonarButton: this._cyberSonarBtn,
sonarRingsSlider: this._cyberSonarRings,
sonarRangeSlider: this._cyberSonarRange,
sonarIntensitySlider: this._cyberSonarIntensity,
sonarOpacitySlider: this._cyberSonarOpacity,
sonarSectorSlider: this._cyberSonarSector,
cleanViewButton: this._cleanViewBtn,
cleanViewExitButton: this._cleanViewExitBtn,
densitySlider: this._detectionDensitySlider,
detectionButton: this._detectionBtn,
allocationButtons: this._detectionAllocationBtns,
fadeSliders: [this._detectionFadeSlider, this._detectionOpacitySlider],
celestialButton: this._celestialBtn,
modelsButton: this._models3dBtn,
modelModeButtons: this._models3dBtn ? this._models3dModeBtns : [],
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);
},
actions: {
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');
setScopeMaskEnabled(enabled);
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
this._syncShareState();
},
setScopeFeather: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
const pct = clampScopeFeatherPct(value);
if (this._scopeFeatherValue)
this._scopeFeatherValue.textContent = `${pct}%`;
setScopeMaskFeather(pct / 100);
this._syncShareState();
},
setSharpenIntensity: (pct) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
if (this._sharpenSliderValue)
this._sharpenSliderValue.textContent = `${pct}%`;
this._applySharpenIntensity(pct / 100);
this._syncShareState();
},
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();
const pct = canonicalizeDensity(value);
this._detectionDensitySlider.value = String(pct);
if (this._detectionDensityValue)
this._detectionDensityValue.textContent = `${pct}%`;
this._applyDetectionDensityFromUi();
this._syncShareState();
},
setAllocation: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
this._setDetectionAllocation(value);
},
setFade: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._applyDetectionFadeFromUi();
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),
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._displayControls?.destroy();
this._displayControls = null;
this._detachDisplayControls?.();
this._detachDisplayControls = null;
}
}
+4 -74
View File
@@ -1,79 +1,9 @@
import { createSegmentedControl } from './segmentedControl.js';
/**
* Bind Display controls to application actions. Inputs remain native DOM controls;
* the caller owns settings and effect state. Destroy before replacing the view.
* @param {{ elements: object, actions: object }} options
* @returns {{ destroy(): void }}
* 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 bindDisplayControls({ elements, actions }) {
const removers = [];
const listen = (element, type, action, read) => {
if (!element) return;
const handler = () => actions[action](...(read ? [read(element)] : []));
element.addEventListener(type, handler);
removers.push(() => element.removeEventListener(type, handler));
};
const integer = (element) => parseInt(element.value, 10);
for (const [name, action] of [
['bloomButton', 'toggleBloom'],
['sharpenButton', 'toggleSharpen'],
['cleanViewButton', 'toggleCleanView'],
['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'],
['hudButton', 'toggleHud'],
['sonarButton', 'toggleSonar'],
['detectionButton', 'cycleDetection'],
['modelsButton', 'toggleModels'],
])
listen(elements[name], 'click', action);
for (const [name, action] of [
['bloomSlider', 'setBloomIntensity'],
['sharpenSlider', 'setSharpenIntensity'],
['scopeFeatherSlider', 'setScopeFeather'],
])
listen(elements[name], 'input', action, integer);
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [
['sonarRingsSlider', 'setSonarRings'],
['sonarRangeSlider', 'setSonarRange'],
['sonarIntensitySlider', 'setSonarIntensity'],
['sonarOpacitySlider', 'setSonarOpacity'],
['sonarSectorSlider', 'setSonarSector'],
])
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const [buttons, valueOf, action] of [
[
elements.allocationButtons || [],
(el) => el.dataset.allocation,
'setAllocation',
],
[
elements.modelModeButtons || [],
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
'setModelsMode',
],
]) {
const control = createSegmentedControl({
buttons,
valueOf,
onChange: (value) => actions[action](value),
});
removers.push(() => control.destroy());
}
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
return {
destroy() {
for (const remove of removers.splice(0)) remove();
},
};
}
/** Keep the native Scope checkbox and feather availability in step with the mask. */
export function syncScopeControls(toggle, featherSlider, enabled) {
if (toggle) toggle.checked = enabled;
if (featherSlider) featherSlider.disabled = !enabled;
-186
View File
@@ -1,186 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { bindDisplayControls } from './displayControls.js';
function element(value = '') {
const el = new EventTarget();
el.value = value;
el.dataset = {};
return el;
}
test('sonar on/off control cannot fire after disposal', () => {
const sonarButton = element();
const calls = [];
const control = bindDisplayControls({
elements: { sonarButton },
actions: { toggleSonar: () => calls.push('toggle') },
});
sonarButton.dispatchEvent(new Event('click'));
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['toggle']);
});
test('controls read current values without preventing native input behavior', () => {
const bloomSlider = element('24');
const hudLayout = element('tactical');
const calls = [];
const control = bindDisplayControls({
elements: { bloomSlider, hudLayout },
actions: {
setBloomIntensity: (value) => calls.push(value),
setHudLayout: (value) => calls.push(value),
},
});
const event = new Event('input', { cancelable: true });
bloomSlider.dispatchEvent(event);
bloomSlider.value = '37';
bloomSlider.dispatchEvent(new Event('input'));
hudLayout.value = 'minimal';
hudLayout.dispatchEvent(new Event('change'));
assert.deepEqual(calls, [24, 37, 'minimal']);
assert.equal(event.defaultPrevented, false);
control.destroy();
});
test('destroyed and replaced controls cannot issue stale actions', () => {
const bloomButton = element();
let oldCalls = 0;
let newCalls = 0;
const first = bindDisplayControls({
elements: { bloomButton },
actions: { toggleBloom: () => oldCalls++ },
});
bloomButton.dispatchEvent(new Event('click'));
first.destroy();
const second = bindDisplayControls({
elements: { bloomButton },
actions: { toggleBloom: () => newCalls++ },
});
first.destroy();
bloomButton.dispatchEvent(new Event('click'));
assert.equal(oldCalls, 1);
assert.equal(newCalls, 1);
second.destroy();
bloomButton.dispatchEvent(new Event('click'));
assert.equal(newCalls, 1);
});
test('the Cyber sonar control is an optional owned display action', () => {
const sonarButton = element();
const sonarRingsSlider = element('9');
const sonarRangeSlider = element('110');
const sonarIntensitySlider = element('65');
const sonarOpacitySlider = element('84');
const sonarSectorSlider = element('32');
const calls = [];
const control = bindDisplayControls({
elements: {
sonarButton,
sonarRingsSlider,
sonarRangeSlider,
sonarIntensitySlider,
sonarOpacitySlider,
sonarSectorSlider,
},
actions: {
toggleSonar: () => calls.push('toggle'),
setSonarRings: (value) => calls.push(['rings', value]),
setSonarRange: (value) => calls.push(['range', value]),
setSonarIntensity: (value) => calls.push(['intensity', value]),
setSonarOpacity: (value) => calls.push(['opacity', value]),
setSonarSector: (value) => calls.push(['sector', value]),
},
});
sonarButton.dispatchEvent(new Event('click'));
sonarRingsSlider.dispatchEvent(new Event('input'));
sonarRangeSlider.dispatchEvent(new Event('input'));
sonarIntensitySlider.dispatchEvent(new Event('input'));
sonarOpacitySlider.dispatchEvent(new Event('input'));
sonarSectorSlider.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
'toggle',
['rings', 9],
['range', 110],
['intensity', 65],
['opacity', 84],
['sector', 32],
]);
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.equal(calls.length, 6);
});
test('style, allocation and model choices retain their current data attributes', () => {
const style = element();
const allocation = element();
const mode = element();
style.dataset.style = 'thermal';
allocation.dataset.allocation = 'balanced';
mode.dataset.mode = 'all';
const calls = [];
const control = bindDisplayControls({
elements: {
styleButtons: [style],
allocationButtons: [allocation],
modelModeButtons: [mode],
},
actions: {
setStyle: (value) => calls.push(value),
setAllocation: (value) => calls.push(value),
setModelsMode: (value) => calls.push(value),
},
});
for (const el of [style, allocation, mode])
el.dispatchEvent(new Event('click'));
mode.dataset.mode = 'unknown';
mode.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['thermal', 'balanced', 'all', 'proximity']);
control.destroy();
});
test('optional controls are absent safely and subscriptions stay instance-owned', () => {
const calls = [];
const a = element();
const b = element();
const one = bindDisplayControls({
elements: { fadeSliders: [null, a] },
actions: { setFade: () => calls.push('a') },
});
const two = bindDisplayControls({
elements: { fadeSliders: [b] },
actions: { setFade: () => calls.push('b') },
});
one.destroy();
a.dispatchEvent(new Event('input'));
b.dispatchEvent(new Event('input'));
assert.deepEqual(calls, ['b']);
two.destroy();
});
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
const scopeToggle = element();
scopeToggle.checked = true;
const scopeFeatherSlider = element('200');
const calls = [];
const controls = bindDisplayControls({
elements: { scopeToggle, scopeFeatherSlider },
actions: {
setScopeEnabled: (value) => calls.push(['enabled', value]),
setScopeFeather: (value) => calls.push(['feather', value]),
},
});
scopeToggle.checked = false;
scopeToggle.dispatchEvent(new Event('change'));
scopeToggle.checked = true;
scopeToggle.dispatchEvent(new Event('change'));
scopeFeatherSlider.dispatchEvent(new Event('input'));
controls.destroy();
scopeToggle.dispatchEvent(new Event('change'));
assert.deepEqual(calls, [
['enabled', false],
['enabled', true],
['feather', 200],
]);
});
+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'],
]);
});
-36
View File
@@ -1,36 +0,0 @@
/**
* Build a native numeric field without listeners, parsing or commit policy.
* Owners retain drafts, validation, input/change timing and disposal. Static
* selects and number inputs share the gev-field surface in their templates;
* options remain owned by each feature (no replacement popup or keyboard code).
* @param {object} options
* @param {Document} [options.documentRef]
* @param {string} [options.className] Additional density/layout hooks.
* @param {number|string} [options.min]
* @param {number|string} [options.max]
* @param {number|string} [options.step]
* @param {number|string} [options.value]
* @param {boolean} [options.disabled]
* @param {string} [options.ariaLabel] Already translated accessible name.
* @returns {HTMLInputElement}
*/
export function createNumberInput({
documentRef = globalThis.document,
className = '',
min,
max,
step,
value,
disabled = false,
ariaLabel,
} = {}) {
const input = documentRef.createElement('input');
input.type = 'number';
input.className = `gev-field${className ? ` ${className}` : ''}`;
for (const [key, setting] of Object.entries({ min, max, step, value })) {
if (setting !== undefined) input[key] = setting;
}
input.disabled = disabled;
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
return input;
}
+36 -22
View File
@@ -1,17 +1,29 @@
import { t } from '../i18n/index.js';
/** Count rendered globe frames while the optional readout is visible. */
export function createFrameRateMonitor({ viewer, documentRef = document }) {
const host = documentRef.getElementById('title-bar');
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;
if (!host || !frameEvent) return { destroy() {} };
// A page without the scene chrome has no title bar to show it in.
if (!documentRef.getElementById('title-bar') || !frameEvent)
return { destroy() {} };
const readout = documentRef.createElement('div');
readout.className = 'frame-rate-readout scene-text-holdout';
readout.hidden = true;
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
readout.title = t('Rendered globe frames per second · toggle with `');
host.appendChild(readout);
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;
@@ -19,7 +31,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
let destroyed = false;
function hide() {
readout.hidden = true;
present({ visible: false });
removeFrameListener?.();
removeFrameListener = null;
clearInterval(timer);
@@ -30,19 +42,20 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
frames = 0;
startedAt = performance.now();
// i18n-ignore: FPS is a unit code, the same in every language
readout.textContent = 'FPS —';
readout.hidden = false;
present({ visible: true, text: 'FPS —' });
removeFrameListener = frameEvent.addEventListener(() => {
frames++;
});
timer = setInterval(() => {
const now = performance.now();
const elapsed = now - startedAt;
readout.textContent =
// i18n-ignore: FPS is a unit code, the same in every language
documentRef.hidden || elapsed <= 0
? 'FPS —'
: `FPS ${Math.round((frames * 1000) / elapsed)}`;
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);
@@ -68,8 +81,8 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
)
return;
event.preventDefault();
if (readout.hidden) show();
else hide();
if (readout.visible) hide();
else show();
}
documentRef.addEventListener('keydown', onKeyDown);
@@ -79,7 +92,8 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
destroyed = true;
hide();
documentRef.removeEventListener('keydown', onKeyDown);
readout.remove();
// A newer monitor's readout stays.
if (store.fps === readout) store.publishFps(null);
},
};
}
+54 -20
View File
@@ -5,19 +5,15 @@ import { createFrameRateMonitor } from './frameRateMonitor.js';
function fixture(t) {
const keys = new Set();
const frames = new Set();
let readout;
const store = {
fps: undefined,
publishFps(next) {
this.fps = next;
},
};
const documentRef = {
hidden: false,
getElementById: () => ({
appendChild: (node) => {
readout = node;
},
}),
createElement: () => ({
remove() {
this.removed = true;
},
}),
getElementById: (id) => (id === 'title-bar' ? {} : null),
addEventListener: (name, handler) => keys.add(handler),
removeEventListener: (name, handler) => keys.delete(handler),
};
@@ -31,14 +27,14 @@ function fixture(t) {
},
},
};
const monitor = createFrameRateMonitor({ viewer, documentRef });
const monitor = createFrameRateMonitor({ viewer, documentRef, store });
t.after(() => monitor.destroy());
return {
monitor,
documentRef,
frames,
keys,
readout: () => readout,
readout: () => store.fps,
press(options = {}) {
const event = {
key: '`',
@@ -58,7 +54,7 @@ test('FPS measures rendered frames only while shown and releases its resources',
let now = 0;
t.mock.method(performance, 'now', () => now);
const f = fixture(t);
assert.equal(f.readout().hidden, true);
assert.equal(f.readout().visible, false);
assert.equal(f.frames.size, 0);
assert.equal(f.press().defaultPrevented, true);
assert.equal(f.frames.size, 1);
@@ -66,26 +62,26 @@ test('FPS measures rendered frames only while shown and releases its resources',
for (const render of f.frames) render();
now = 1000;
t.mock.timers.tick(1000);
assert.equal(f.readout().textContent, 'FPS 30');
assert.equal(f.readout().text, 'FPS 30');
now = 2000;
t.mock.timers.tick(1000);
assert.equal(
f.readout().textContent,
f.readout().text,
'FPS 0',
'idle rendering is not reported as display refresh rate',
);
f.press();
assert.equal(f.readout().hidden, true);
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().textContent, 'FPS —');
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().removed, true);
assert.equal(f.readout(), null, 'destroy withdraws the readout');
});
test('typing, modified keys, composition and repeated presses do not toggle FPS', (t) => {
@@ -103,7 +99,45 @@ test('typing, modified keys, composition and repeated presses do not toggle FPS'
{ key: 'a' },
]) {
f.press(options);
assert.equal(f.readout().hidden, true);
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);
});
+57 -116
View File
@@ -1,18 +1,10 @@
/** Session-local file controls, serial loading and application-owned navigation. */
import {
GEO_FILE_ACCEPT,
GeoImportError,
MAX_IMPORTED_FILES,
loadGeoFile,
} from '../data/geoFileImport.js';
import {
formatNumber,
msg,
registerUnsavedWork,
t,
tn,
translateKnown,
} from '../i18n/index.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.
@@ -29,9 +21,13 @@ function localizedValues(values) {
}
/**
* Wire the Import control.
* @param {{viewer: object, load?: Function, document?: Document}} deps
* 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}
@@ -44,12 +40,12 @@ export function initGeoFileImport({
navigation = null,
ground = null,
subscribeMapSource = null,
publish = () => {},
connect = () => () => {},
}) {
const button = doc?.getElementById('geo-import-button');
const input = doc?.getElementById('geo-import-input');
const listEl = doc?.getElementById('geo-import-list');
const hint = doc?.getElementById('geo-import-hint');
if (!viewer || !button || !input || !listEl) return null;
// 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 = [];
@@ -59,13 +55,9 @@ export function initGeoFileImport({
let destroyed = false;
let dragDepth = 0;
let cameraIntent = 0;
let hint = '';
const lifetime = new AbortController();
let removeMapSource;
// Imports live for the page session: switching language (a reload) asks
// first while any are on the map.
const unregisterUnsavedWork = registerUnsavedWork(() =>
!destroyed && imports.length ? msg('imported map files') : null,
);
const listen = (target, type, listener, options) => {
if (!target?.addEventListener) return;
@@ -73,7 +65,8 @@ export function initGeoFileImport({
listeners.push([target, type, listener, options]);
};
const setHint = (text) => {
if (hint) hint.textContent = text || '';
hint = text || '';
present();
};
const render = () => viewer.scene?.requestRender?.();
let groundTimer,
@@ -125,83 +118,45 @@ export function initGeoFileImport({
if (!pending) scheduleGround();
});
/** Which row button has focus, so a re-render can give it back. */
function focusedAction() {
const active = doc.activeElement;
if (!active || !listEl.contains?.(active)) return null;
const row = active.closest?.('[data-import-id]');
return row
? { id: row.dataset.importId, action: active.dataset?.action }
: null;
}
function restoreFocus(was) {
if (!was) return;
const rows = [...listEl.querySelectorAll('[data-import-id]')];
let row = rows.find((r) => r.dataset.importId === was.id);
// The row is gone (removed): the next row's same control, else the
// previous row's, else the Import button.
if (!row && was.index != null)
row = rows[Math.min(was.index, rows.length - 1)];
const target =
row?.querySelector?.(`button[data-action="${was.action}"]`) ||
(row ? row.querySelector?.('button') : button);
target?.focus?.();
}
function renderList(keep = focusedAction()) {
listEl.replaceChildren(
...imports.map((item) => {
const row = doc.createElement('li');
row.className = 'geo-import-row';
row.dataset.importId = item.id;
const swatch = doc.createElement('span');
/** 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.
swatch.className = `geo-import-swatch${item.ownStyles ? ' own-styles' : ''}`;
if (!item.ownStyles) swatch.style.background = item.color;
const name = doc.createElement('span');
name.className = 'geo-import-name';
// textContent, never markup: the name comes from the file system.
name.textContent = item.name;
name.title = `${item.name} — ${item.format.toUpperCase()}, ${tn(
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') : ''}`;
const actions = [
[
item.visible ? 'visibility' : 'visibility_off',
item.visible
)}${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 }),
'toggle',
],
['my_location', t('Fly to {name}', { name: item.name }), 'fly'],
['close', t('Remove {name}', { name: item.name }), 'remove'],
].map(([icon, label, action]) => {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = 'geo-import-action';
btn.dataset.action = action;
btn.title = label;
btn.setAttribute('aria-label', label);
if (action === 'toggle')
btn.setAttribute('aria-pressed', String(item.visible));
const glyph = doc.createElement('span');
glyph.className = 'material-symbols-outlined';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = icon;
btn.append(glyph);
return btn;
});
row.append(swatch, name, ...actions);
return row;
}),
);
listEl.classList.toggle('visible', imports.length > 0);
restoreFocus(keep);
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;
@@ -213,7 +168,7 @@ export function initGeoFileImport({
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
if (item.visible) scheduleGround();
renderList();
present();
render();
return true;
}
@@ -258,12 +213,10 @@ export function initGeoFileImport({
function remove(id) {
const at = imports.findIndex((item) => item.id === id);
if (at < 0) return false;
const keep = focusedAction();
if (keep) keep.index = at;
const [item] = imports.splice(at, 1);
viewer.dataSources?.remove?.(item.dataSource, true);
item.revoke?.();
renderList(keep);
present();
render();
return true;
}
@@ -348,7 +301,7 @@ export function initGeoFileImport({
}
loaded.mount?.();
imports.push(item);
renderList();
present();
render();
outcomes.push({
name: fileName,
@@ -370,22 +323,12 @@ export function initGeoFileImport({
}
// ---- controls ----------------------------------------------------------
input.accept = GEO_FILE_ACCEPT;
listen(button, 'click', () => input.click());
listen(input, 'change', () => {
const files = [...(input.files || [])];
// Clear the selection so choosing the same file again fires `change`.
input.value = '';
if (files.length) void importFiles(files);
});
listen(listEl, 'click', (event) => {
const btn = event.target?.closest?.('button[data-action]');
const id = btn?.closest?.('[data-import-id]')?.dataset.importId;
if (!btn || !id) return;
const action = btn.dataset.action;
if (action === 'toggle') setVisible(id, !find(id)?.visible);
else if (action === 'fly') flyTo(id);
else if (action === 'remove') remove(id);
// 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 ----------------------------------------------
@@ -428,7 +371,7 @@ export function initGeoFileImport({
if (files.length) void importFiles(files);
});
renderList();
present();
const handle = {
importFiles,
@@ -453,7 +396,7 @@ export function initGeoFileImport({
destroy() {
if (destroyed) return;
destroyed = true;
unregisterUnsavedWork();
disconnect();
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
@@ -465,8 +408,6 @@ export function initGeoFileImport({
for (const [target, type, listener, options] of listeners.splice(0))
target.removeEventListener(type, listener, options);
setDragging(false);
listEl.replaceChildren();
listEl.classList.remove('visible');
setHint('');
if (globalThis.window?.__gevGeoImport === handle)
delete globalThis.window.__gevGeoImport;
+60 -183
View File
@@ -1,14 +1,14 @@
// 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, list rows, the hint,
// drop handling, the file limit, and what destroy() gives back. Real parsing
// and rendering run in `scripts/qa-geo-import.mjs`.
// 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';
import { describeUnsavedWork } from '../i18n/index.js';
class FakeClassList {
constructor() {
@@ -31,46 +31,12 @@ class FakeClassList {
}
}
// The one focused element across every fake document (tests run one at a time).
const focus = { active: null };
class FakeElement {
constructor(tagName = 'div', id = '') {
this.tagName = tagName.toUpperCase();
this.id = id;
this.classList = new FakeClassList();
this.dataset = {};
this.style = {};
this.attributes = new Map();
this.children = [];
this.parent = null;
this.listeners = [];
this.textContent = '';
this.value = '';
this.clicks = 0;
}
set className(v) {
this.classList = new FakeClassList();
String(v)
.split(/\s+/)
.filter(Boolean)
.forEach((n) => this.classList.add(n));
}
setAttribute(n, v) {
this.attributes.set(n, String(v));
}
getAttribute(n) {
return this.attributes.get(n) ?? null;
}
append(...kids) {
for (const k of kids) {
k.parent = this;
this.children.push(k);
}
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
addEventListener(type, listener, options) {
this.listeners.push({ type, listener, options });
@@ -91,71 +57,24 @@ class FakeElement {
...event,
});
}
click() {
this.clicks += 1;
this.emit('click');
}
focus() {
focus.active = this;
}
contains(el) {
for (let at = el; at; at = at.parent) if (at === this) return true;
return false;
}
*walk() {
for (const kid of this.children) {
yield kid;
yield* kid.walk();
}
}
querySelectorAll(selector) {
if (selector !== '[data-import-id]') throw new Error(`fake: ${selector}`);
return [...this.walk()].filter((el) => el.dataset.importId);
}
querySelector(selector) {
const exact = /^button\[data-action="([a-z]+)"\]$/.exec(selector);
for (const el of this.walk()) {
if (el.tagName !== 'BUTTON') continue;
if (selector === 'button' || (exact && el.dataset.action === exact[1]))
return el;
}
return null;
}
closest(selector) {
for (let el = this; el; el = el.parent) {
if (
selector === 'button[data-action]' &&
el.tagName === 'BUTTON' &&
el.dataset.action
)
return el;
if (selector === '[data-import-id]' && el.dataset.importId) return el;
}
return null;
}
}
function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
focus.active = null;
const els = {
'geo-import-button': new FakeElement('button', 'geo-import-button'),
'geo-import-input': new FakeElement('input', 'geo-import-input'),
'geo-import-list': new FakeElement('ul', 'geo-import-list'),
'geo-import-hint': new FakeElement('span', 'geo-import-hint'),
};
const document = {
body: new FakeElement('body'),
getElementById: (id) => els[id] ?? null,
createElement: (tag) => new FakeElement(tag),
get activeElement() {
return focus.active;
},
};
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) },
@@ -193,6 +112,13 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
...(navigation ? { navigation } : {}),
...(subscribeMapSource ? { subscribeMapSource } : {}),
flyToSource: flyToSource || ((_viewer, ds) => flights.push(ds)),
publish: (view) => {
shown = view;
},
connect: (next) => {
actions = next;
return () => (actions = null);
},
});
return {
tool,
@@ -205,25 +131,22 @@ function setup({ load, navigation, flyToSource, subscribeMapSource } = {}) {
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 = (els) => els['geo-import-list'].children;
const action = (row, name) =>
row.children.find((c) => c.dataset.action === name);
// Runs first: tools left by later tests would answer the same registry.
test('imported files make the language switch ask first', async () => {
const t = setup();
assert.deepEqual(describeUnsavedWork(), []);
await t.tool.importFiles([file('a.geojson')]);
assert.deepEqual(describeUnsavedWork(), ['imported map files']);
t.tool.remove(t.tool.list()[0].id);
assert.deepEqual(describeUnsavedWork(), [], 'nothing left to lose');
await t.tool.importFiles([file('b.kml')]);
t.tool.destroy();
assert.deepEqual(describeUnsavedWork(), [], 'destroy unregisters');
});
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 };
@@ -237,43 +160,30 @@ test('returns null without its markup or viewer', () => {
);
});
test('the Import button opens the picker and a pick imports each file', async () => {
test('a pick imports each file', async () => {
const t = setup();
t.els['geo-import-button'].click();
assert.equal(t.els['geo-import-input'].clicks, 1);
assert.equal(
t.els['geo-import-input'].accept,
'.geojson,.json,.kml,.kmz,.gpx',
);
const out = await t.tool.importFiles([file('a.geojson'), file('b.kml')]);
assert.deepEqual(
out.map((o) => o.ok),
[true, true],
);
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.els).length, 2);
assert.ok(t.els['geo-import-list'].classList.contains('visible'));
assert.equal(rows(t).length, 2);
assert.equal(t.flights.length, 1, 'the first file of a batch is flown to');
assert.equal(t.els['geo-import-hint'].textContent, 'Added 2 files.');
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 text, never markup', async () => {
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')]);
const name = rows(t.els)[0].children.find((c) =>
c.classList.contains('geo-import-name'),
);
assert.equal(name.textContent, '<img src=x onerror=alert(1)>.kml');
assert.equal(name.innerHTML, undefined);
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 () => {
@@ -281,19 +191,14 @@ test('row buttons hide, show, fly to and remove a file', async () => {
await t.tool.importFiles([file('a.gpx')]);
const [{ id }] = t.tool.list();
const ds = t.added[0];
// the click lands on the glyph inside the button, as in a browser
const glyphClick = (name) =>
t.els['geo-import-list'].emit('click', {
target: action(rows(t.els)[0], name).children[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.els)[0], 'toggle').getAttribute('aria-pressed'),
'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);
@@ -301,7 +206,7 @@ test('row buttons hide, show, fly to and remove a file', async () => {
assert.deepEqual(t.removed, [{ ds, destroy: true }]);
assert.deepEqual(t.revoked, ['a.gpx']);
assert.equal(t.tool.list().length, 0);
assert.ok(!t.els['geo-import-list'].classList.contains('visible'));
assert.deepEqual(rows(t), []);
assert.equal(t.tool.remove(id), false);
});
@@ -319,7 +224,7 @@ test('a refused file says why and does not stop the rest of the batch', async ()
assert.equal(out[0].error, 'That KML file is not valid XML.');
assert.match(out[1].error, /could not be read\./);
assert.match(
t.els['geo-import-hint'].textContent,
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);
@@ -380,14 +285,10 @@ test('destroy removes every file and listener and the window handle', async () =
t.tool.destroy();
assert.equal(t.removed.length, 2);
assert.deepEqual(t.revoked.sort(), ['a.geojson', 'b.geojson']);
for (const el of [
t.viewer.container,
t.els['geo-import-button'],
t.els['geo-import-input'],
t.els['geo-import-list'],
])
assert.equal(el.listeners.length, 0);
assert.equal(rows(t.els).length, 0);
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 {
@@ -427,9 +328,7 @@ test('the application composition wires the import and owns its teardown', () =>
new URL('../app/tools.js', import.meta.url),
'utf8',
);
const drawAt = tools.indexOf(
'const drawTool = initDrawTool({ viewer, annotations });',
);
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.
@@ -438,8 +337,13 @@ test('the application composition wires the import and owns its teardown', () =>
/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('./templates/display-controls.html', import.meta.url),
new URL('./views/panels/GeoImport.vue', import.meta.url),
'utf8',
);
for (const id of [
@@ -450,7 +354,7 @@ test('the application composition wires the import and owns its teardown', () =>
])
assert.ok(
markup.includes(`id="${id}"`),
`${id} missing from the DISPLAY template`,
`${id} missing from the Import template`,
);
});
@@ -667,32 +571,6 @@ test('a refused deferred stamp (Cockpit) imports without flying', async () => {
assert.equal(flown.length, 0);
});
test('keyboard focus stays on the control after hide, show and remove', async () => {
const t = setup();
await t.tool.importFiles([file('a.geojson'), file('b.geojson')]);
// Clicks reach the list's delegated listener, as they bubble in a page.
const press = (btn) =>
t.els['geo-import-list'].emit('click', { target: btn });
const [first] = rows(t.els);
action(first, 'toggle').focus();
press(action(first, 'toggle'));
// The list was rebuilt; focus is on the NEW toggle of the same row.
const again = rows(t.els)[0];
assert.notEqual(again, first);
assert.equal(t.document.activeElement, action(again, 'toggle'));
assert.equal(action(again, 'toggle').getAttribute('aria-pressed'), 'false');
press(action(again, 'toggle'));
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'toggle'));
// Remove the first row: focus moves to the same control on the next row.
action(rows(t.els)[0], 'remove').focus();
press(action(rows(t.els)[0], 'remove'));
assert.equal(rows(t.els).length, 1);
assert.equal(t.document.activeElement, action(rows(t.els)[0], 'remove'));
// Remove the last row: focus goes back to the Import button.
press(action(rows(t.els)[0], 'remove'));
assert.equal(t.document.activeElement, t.els['geo-import-button']);
});
test('KML and KMZ rows say they keep their own styles instead of showing a color', async () => {
const t = setup({
load: async (f) => ({
@@ -705,12 +583,11 @@ test('KML and KMZ rows say they keep their own styles instead of showing a color
}),
});
await t.tool.importFiles([file('a.kml'), file('b.gpx')]);
const [kml, gpx] = rows(t.els);
assert.ok(kml.children[0].classList.contains('own-styles'));
assert.equal(kml.children[0].style.background, undefined);
assert.match(kml.children[1].title, /drawn with its own styles/);
assert.ok(!gpx.children[0].classList.contains('own-styles'));
assert.ok(gpx.children[0].style.background);
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 () => {
@@ -725,8 +602,8 @@ test('the map-object count in a row title keeps digit grouping', async () => {
}),
});
await t.tool.importFiles([file('big.geojson')]);
const [row] = rows(t.els);
assert.match(row.children[1].title, /12,345 map objects/);
const [row] = rows(t);
assert.match(row.title, /12,345 map objects/);
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;
});
+92 -86
View File
@@ -1,3 +1,8 @@
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) {
@@ -12,6 +17,9 @@ function defaultViewportWidth() {
);
}
// 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`;
}
@@ -19,17 +27,19 @@ function defaultValueText(beforePercent, afterPercent) {
/**
* Split divider for before/after imagery comparison.
*
* Owns the DOM line + handle, pointer capture, keyboard, ARIA, a CSS custom
* property and scene.splitPosition. `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 removes the
* listeners and line, clears the CSS property and restores the scene split
* captured at creation once.
* 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,
parent = document.body,
initialValue = 0.5,
id,
handleClass,
@@ -44,34 +54,40 @@ export function createImagerySplit({
getViewportWidth = defaultViewportWidth,
onChange,
requestRender,
store = useImagerySplitStore(applicationPinia()),
} = {}) {
const key = ++splitCount;
const restoreValue = scene ? (scene.splitPosition ?? null) : null;
const line = document.createElement('div');
if (id) line.id = id;
const before = document.createElement('span');
const after = document.createElement('span');
const handle = document.createElement('button');
before.className = 'before';
after.className = 'after';
before.textContent = beforeLabel;
if (beforeTitle != null) before.title = beforeTitle;
after.textContent = afterLabel;
if (afterTitle != null) after.title = afterTitle;
handle.type = 'button';
if (handleClass) handle.className = handleClass;
handle.setAttribute('role', 'slider');
if (ariaLabel != null) handle.setAttribute('aria-label', ariaLabel);
handle.setAttribute('aria-valuemin', '0');
handle.setAttribute('aria-valuemax', '100');
handle.setAttribute('aria-orientation', 'horizontal');
handle.textContent = '↔';
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;
@@ -82,12 +98,7 @@ export function createImagerySplit({
cssTarget.style.setProperty(cssProperty, css);
cssValue = css;
}
const beforePercent = Math.round(value * 100);
handle.setAttribute('aria-valuenow', String(beforePercent));
handle.setAttribute(
'aria-valuetext',
formatValueText(beforePercent, 100 - beforePercent),
);
if (changed) publish();
if (changed && render) requestRender?.();
if (changed && notify) onChange?.(value);
return changed;
@@ -99,54 +110,47 @@ export function createImagerySplit({
apply(Number(clientX) / viewportWidth, { notify: true });
}
function handlePointerDown(event) {
if (event.button != null && event.button !== 0) return;
event.preventDefault?.();
dragging = true;
pointerId = event.pointerId ?? null;
handle.setPointerCapture?.(event.pointerId);
setFromClientX(event.clientX);
}
// 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,
});
},
};
function handlePointerMove(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
event.preventDefault?.();
setFromClientX(event.clientX);
}
function finishPointerDrag(event) {
if (!dragging) return;
if (pointerId != null && event.pointerId !== pointerId) return;
handle.releasePointerCapture?.(event.pointerId);
dragging = false;
pointerId = null;
}
function handleKeyDown(event) {
if (!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,
});
}
const listeners = [
['pointerdown', handlePointerDown],
['pointermove', handlePointerMove],
['pointerup', finishPointerDrag],
['pointercancel', finishPointerDrag],
['keydown', handleKeyDown],
];
for (const [type, listener] of listeners)
handle.addEventListener(type, listener);
line.append(before, handle, after);
parent.appendChild(line);
apply(initialValue, { render: false });
const offLocale = onLocaleChange(publish);
return {
getValue() {
@@ -157,18 +161,20 @@ export function createImagerySplit({
return apply(next);
},
setVisible(visible) {
line.hidden = !visible;
if (hidden === !visible) return;
hidden = !visible;
publish();
},
destroy() {
if (destroyed) return;
destroyed = true;
if (dragging && pointerId != null)
handle.releasePointerCapture?.(pointerId);
captor?.releasePointerCapture?.(pointerId);
dragging = false;
pointerId = null;
for (const [type, listener] of listeners)
handle.removeEventListener?.(type, listener);
line.remove();
captor = null;
destroyed = true;
offLocale();
store.withdraw(key);
if (cssProperty) cssTarget.style.removeProperty(cssProperty);
if (scene && restoreValue != null) scene.splitPosition = restoreValue;
},
+79 -91
View File
@@ -2,51 +2,35 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createImagerySplit } from './imagerySplit.js';
class FakeElement {
constructor(tagName) {
this.tagName = tagName;
this.children = [];
this.removed = false;
this.captured = [];
this.released = [];
this._attributes = new Map();
this._listeners = new Map();
}
setAttribute(name, value) {
this._attributes.set(name, value);
}
getAttribute(name) {
return this._attributes.get(name) ?? null;
}
addEventListener(type, handler) {
this._listeners.set(type, handler);
}
removeEventListener(type, handler) {
if (this._listeners.get(type) === handler) this._listeners.delete(type);
}
dispatch(type, event = {}) {
return this._listeners.get(type)?.(event);
}
setPointerCapture(id) {
this.captured.push(id);
}
releasePointerCapture(id) {
this.released.push(id);
}
append(...children) {
this.children.push(...children);
}
appendChild(child) {
this.children.push(child);
return child;
}
remove() {
this.removed = true;
}
/** 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 body = new FakeElement('body');
const cssCalls = [];
const changes = [];
let renders = 0;
@@ -61,15 +45,9 @@ function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
},
},
};
const previous = globalThis.document;
globalThis.document = {
body,
createElement: (tagName) => new FakeElement(tagName),
documentElement: cssTarget,
};
const store = fakeStore();
const split = createImagerySplit({
scene,
parent: body,
initialValue,
id: 'split-line',
handleClass: 'split-handle',
@@ -83,37 +61,45 @@ function fixture({ initialValue = 0.5, splitPosition = 0.37 } = {}) {
requestRender: () => {
renders += 1;
},
store,
});
globalThis.document = previous;
const line = body.children[0];
const handle = line.children.find((child) => child.tagName === 'button');
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,
body,
store,
line,
view: () => line()?.view,
handle,
dispatch,
cssCalls,
changes,
renders: () => renders,
};
}
test('the split builds its line and applies the initial value without notifying', () => {
test('the split publishes its line and applies the initial value without notifying', () => {
const env = fixture({ initialValue: 0.62 });
assert.equal(env.line.id, 'split-line');
assert.equal(env.line.children[0].textContent, 'A');
assert.equal(env.line.children[0].title, 'Before');
assert.equal(env.line.children[2].textContent, 'B');
assert.equal(env.line.children[2].title, 'After');
assert.equal(env.handle.className, 'split-handle');
assert.equal(env.handle.getAttribute('role'), 'slider');
assert.equal(env.handle.getAttribute('aria-label'), 'Divider');
assert.equal(env.handle.getAttribute('aria-valuenow'), '62');
assert.equal(
env.handle.getAttribute('aria-valuetext'),
'A 62 percent, B 38 percent',
);
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, []);
@@ -125,7 +111,7 @@ 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.handle.dispatch('keydown', {
env.dispatch('keydown', {
key,
shiftKey,
preventDefault() {
@@ -140,10 +126,10 @@ test('keyboard steps 1 percent, 5 percent with Shift, and jumps to Home and End'
assert.equal(env.scene.splitPosition, 0.46);
assert.equal(key('Home'), true);
assert.equal(env.scene.splitPosition, 0);
assert.equal(env.handle.getAttribute('aria-valuenow'), '0');
assert.equal(env.view().valueNow, 0);
assert.equal(key('End'), true);
assert.equal(env.scene.splitPosition, 1);
assert.equal(env.handle.getAttribute('aria-valuenow'), '100');
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]);
@@ -168,15 +154,15 @@ test('values clamp to the unit range and only real changes notify or render', ()
[],
'setValue is the owner writing; it never echoes',
);
env.handle.dispatch('keydown', { key: 'Home', preventDefault() {} });
env.handle.dispatch('keydown', { key: 'ArrowLeft', preventDefault() {} });
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.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
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, [
@@ -189,7 +175,7 @@ test('values clamp to the unit range and only real changes notify or render', ()
test('pointer drags follow the primary pointer only', () => {
const env = fixture();
env.handle.dispatch('pointerdown', {
env.dispatch('pointerdown', {
button: 2,
clientX: 900,
pointerId: 1,
@@ -200,7 +186,7 @@ test('pointer drags follow the primary pointer only', () => {
0.5,
'non-primary buttons never start a drag',
);
env.handle.dispatch('pointermove', {
env.dispatch('pointermove', {
clientX: 900,
pointerId: 1,
preventDefault() {},
@@ -210,7 +196,7 @@ test('pointer drags follow the primary pointer only', () => {
0.5,
'moves without a drag are ignored',
);
env.handle.dispatch('pointerdown', {
env.dispatch('pointerdown', {
button: 0,
clientX: 250,
pointerId: 4,
@@ -218,7 +204,7 @@ test('pointer drags follow the primary pointer only', () => {
});
assert.equal(env.scene.splitPosition, 0.25);
assert.deepEqual(env.handle.captured, [4]);
env.handle.dispatch('pointermove', {
env.dispatch('pointermove', {
clientX: 900,
pointerId: 9,
preventDefault() {},
@@ -228,8 +214,8 @@ test('pointer drags follow the primary pointer only', () => {
0.25,
'a different pointer id is ignored',
);
env.handle.dispatch('pointerup', { pointerId: 9 });
env.handle.dispatch('pointermove', {
env.dispatch('pointerup', { pointerId: 9 });
env.dispatch('pointermove', {
clientX: 750,
pointerId: 4,
preventDefault() {},
@@ -239,7 +225,7 @@ test('pointer drags follow the primary pointer only', () => {
0.75,
'the drag survives a foreign pointerup',
);
env.handle.dispatch('pointermove', {
env.dispatch('pointermove', {
clientX: Number.NaN,
pointerId: 4,
preventDefault() {},
@@ -249,9 +235,9 @@ test('pointer drags follow the primary pointer only', () => {
0.75,
'unusable coordinates are ignored',
);
env.handle.dispatch('pointerup', { pointerId: 4 });
env.dispatch('pointerup', { pointerId: 4 });
assert.deepEqual(env.handle.released, [4]);
env.handle.dispatch('pointermove', {
env.dispatch('pointermove', {
clientX: 100,
pointerId: 4,
preventDefault() {},
@@ -263,23 +249,24 @@ test('pointer drags follow the primary pointer only', () => {
test('visibility toggles the line without touching the value', () => {
const env = fixture();
env.split.setVisible(false);
assert.equal(env.line.hidden, true);
assert.equal(env.view().hidden, true);
env.split.setVisible(true);
assert.equal(env.line.hidden, false);
assert.equal(env.view().hidden, false);
assert.equal(env.split.getValue(), 0.5);
});
test('destroy removes the line, clears the CSS property, restores the scene split once, and is idempotent', () => {
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.handle.dispatch('pointerdown', {
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.removed, true);
assert.equal(env.line(), null);
assert.deepEqual(
env.handle.released,
[2],
@@ -287,7 +274,6 @@ test('destroy removes the line, clears the CSS property, restores the scene spli
);
assert.equal(env.cssCalls.at(-1)[0], 'remove');
assert.equal(env.scene.splitPosition, 0.37);
assert.equal(env.handle._listeners.size, 0);
env.scene.splitPosition = 0.9;
env.split.destroy();
assert.equal(
@@ -302,6 +288,8 @@ test('destroy removes the line, clears the CSS property, restores the scene spli
'a destroyed split ignores writes',
);
assert.equal(env.scene.splitPosition, 0.9);
env.handle.dispatch('keydown', { key: 'End', preventDefault() {} });
assert.deepEqual(env.changes, [0.1], 'listeners are gone');
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 @@
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>
+73
View File
@@ -0,0 +1,73 @@
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);
});
+52
View File
@@ -0,0 +1,52 @@
<script setup>
import UiIcon from './UiIcon.vue';
import UiTooltip from './UiTooltip.vue';
import { RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS } from './rail.js';
defineProps({
/** @type {import('vue').PropType<import('./rail.js').UiRailItem[]>} */
items: { type: Array, required: true },
/** The selected item's id, or null. */
active: { type: String, default: null },
/** `left` or `right`: the screen edge the rail sits on. */
side: { type: String, required: true },
/** The nav's accessible name. */
label: { type: String, required: true },
/**
* A panel is open beside the rail: the open item's tip is redundant with the
* panel's title, and the other tips wait for the pointer to rest.
*/
panelOpen: { type: Boolean, default: false },
});
const emit = defineEmits(['select']);
</script>
<template>
<nav class="ui-rail" :class="`ui-rail--${side}`" :aria-label="label">
<template v-for="item in items" :key="item.id">
<i v-if="item.separatorBefore" class="ui-rail__sep" aria-hidden="true" />
<UiTooltip
:text="item.label"
:side="side === 'left' ? 'right' : 'left'"
:disabled="panelOpen && item.id === active"
:delay="panelOpen ? RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS : 0"
>
<button
type="button"
class="ui-rail__item"
:class="{ 'ui-rail__item--on': item.id === active }"
:data-rail-item="item.id"
:aria-label="item.label"
:aria-pressed="item.id === active"
:disabled="item.disabled"
@click="emit('select', item.id)"
>
<UiIcon :name="item.icon" />
<span v-if="item.badge !== undefined" class="ui-rail__badge">{{
item.badge
}}</span>
</button>
</UiTooltip>
</template>
</nav>
</template>
+119
View File
@@ -0,0 +1,119 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let UiSegmented;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
UiSegmented = (await import('./UiSegmented.vue')).default;
});
after(() => uninstall());
const options = [
{ value: 'a', label: 'Alpha', attrs: { id: 'seg-a', 'data-v': 'a' } },
{ value: 'b', label: 'Beta', disabled: true },
{ value: 'c', label: 'Gamma' },
];
function mountGroup(props = {}) {
return mount(UiSegmented, {
attachTo: document.body,
props: { options, label: 'Pick', modelValue: 'a', ...props },
});
}
const tabStops = (wrapper) =>
wrapper.findAll('button').map((b) => b.attributes('tabindex') ?? null);
test('a named radio group with Core and kit classes on every option', () => {
const wrapper = mountGroup({ itemClass: 'pp-mode-btn' });
const group = wrapper.get('[role="radiogroup"]');
assert.equal(group.attributes('aria-label'), 'Pick');
assert.ok(group.classes().includes('gev-segmented'));
const [a, b, c] = wrapper.findAll('button');
assert.equal(a.text(), 'Alpha');
assert.equal(a.attributes('id'), 'seg-a');
assert.equal(a.attributes('data-v'), 'a');
assert.equal(a.attributes('type'), 'button');
assert.equal(a.attributes('aria-checked'), 'true');
assert.ok(a.classes().includes('active'));
assert.ok(a.classes().includes('ui-segmented__item--on'));
assert.ok(a.classes().includes('gev-segment'));
assert.ok(a.classes().includes('pp-mode-btn'));
assert.equal(b.attributes('disabled'), '');
assert.equal(a.attributes('disabled'), undefined);
assert.equal(c.attributes('aria-checked'), 'false');
assert.equal(c.classes().includes('active'), false);
wrapper.unmount();
});
test('a click asks for its option; a disabled one asks for nothing', async () => {
const wrapper = mountGroup();
const [, b, c] = wrapper.findAll('button');
await c.trigger('click');
await b.trigger('click');
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]);
wrapper.unmount();
});
test('keyboard none: every option is a tab stop and arrows do nothing', async () => {
const wrapper = mountGroup({ keyboard: 'none', activeClass: 'is-active' });
assert.deepEqual(tabStops(wrapper), [null, null, null]);
assert.ok(wrapper.get('#seg-a').classes().includes('is-active'));
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
assert.equal(wrapper.emitted('update:modelValue'), undefined);
wrapper.unmount();
});
test('keyboard arrows: one roving stop, arrows skip disabled options and wrap', async () => {
const wrapper = mountGroup();
assert.deepEqual(tabStops(wrapper), ['0', '-1', '-1']);
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c'], ['c']]);
await wrapper.setProps({ modelValue: 'c' });
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '0']);
wrapper.unmount();
});
test('keyboard radio: Home and End reach the ends', async () => {
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'c' });
const [a, , c] = wrapper.findAll('button');
await c.trigger('keydown', { key: 'Home' });
await wrapper.setProps({ modelValue: 'a' });
await a.trigger('keydown', { key: 'End' });
assert.deepEqual(wrapper.emitted('update:modelValue'), [['a'], ['c']]);
wrapper.unmount();
});
test('a key that lands where it started moves focus without a change', async () => {
const wrapper = mountGroup({ keyboard: 'radio', modelValue: 'a' });
await wrapper.get('#seg-a').trigger('keydown', { key: 'Home' });
assert.equal(wrapper.emitted('update:modelValue'), undefined);
assert.ok(document.activeElement === wrapper.get('#seg-a').element);
wrapper.unmount();
});
test('a disabled group answers nothing and leaves the tab order', async () => {
const wrapper = mountGroup({ disabled: true });
assert.deepEqual(tabStops(wrapper), ['-1', '-1', '-1']);
await wrapper.get('#seg-a').trigger('click');
await wrapper.get('#seg-a').trigger('keydown', { key: 'ArrowRight' });
assert.equal(wrapper.emitted('update:modelValue'), undefined);
wrapper.unmount();
});
test('a radio group moves from the focused option, an arrows group from the selection', async () => {
// The selection was restored to c while a keeps focus.
const radio = mountGroup({ keyboard: 'radio', modelValue: 'c' });
await radio.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
assert.deepEqual(radio.emitted('update:modelValue'), [['c']]);
radio.unmount();
const arrows = mountGroup({ keyboard: 'arrows', modelValue: 'c' });
await arrows.get('#seg-a').trigger('keydown', { key: 'ArrowLeft' });
assert.deepEqual(arrows.emitted('update:modelValue'), [['a']]);
arrows.unmount();
});
+126
View File
@@ -0,0 +1,126 @@
<script setup>
import { computed } from 'vue';
// Core's segmented control, with v-model, options, label, stacked and
// disabled. It adds `keyboard`, because its groups keep their own policy: `none`
// (Tab reaches every option and arrows do nothing), `arrows` (a roving tab
// stop that arrows move) or `radio` (arrows plus Home and
// End). Each option may carry `attrs` (an id, data attributes, a tip), and
// Core's classes sit beside the kit's: `gev-segmented`, `gev-segment` and the
// selected option's `activeClass`. Not mirrored yet: an option's icon.
const props = defineProps({
modelValue: { type: String, default: undefined },
/** `{value, label, icon?, disabled?, attrs?}`, labels already translated. */
options: { type: Array, required: true },
/** The group's accessible name, already translated. */
label: { type: String, required: true },
stacked: { type: Boolean, default: false },
/** The whole group is off: no option is in the tab order or answers. */
disabled: { type: Boolean, default: false },
keyboard: {
type: String,
default: 'arrows',
validator: (value) => ['none', 'arrows', 'radio'].includes(value),
},
/** Core's class on every option, beside `gev-segment`. */
itemClass: { type: String, default: undefined },
/** Core's class on the selected option. */
activeClass: { type: String, default: 'active' },
});
const emit = defineEmits(['update:modelValue']);
// The option buttons, by index, for focus; not reactive state.
const items = [];
const selectedIndex = computed(() =>
props.options.findIndex((option) => option.value === props.modelValue),
);
const optionOff = (option) => props.disabled || !!option.disabled;
/** The roving tab stop; with no selection, the first option. */
const tabStop = computed(() =>
props.disabled ? -1 : Math.max(0, selectedIndex.value),
);
function tabindex(index) {
if (props.keyboard === 'none') return undefined;
return index === tabStop.value ? 0 : -1;
}
function choose(index) {
const option = props.options[index];
if (!option || optionOff(option)) return;
emit('update:modelValue', option.value);
}
/**
* Select the option `delta` away from `from`, wrapping past disabled ones, or
* `to` an end. A key that lands where it started (Home on the first) only
* moves focus.
*/
function move({ delta = 0, to = null, from }) {
const count = props.options.length;
if (!count || props.disabled) return;
const enabled = props.options
.map((option, index) => (option.disabled ? -1 : index))
.filter((index) => index >= 0);
if (!enabled.length) return;
let target;
if (to === 'first') target = enabled[0];
else if (to === 'last') target = enabled.at(-1);
else {
target = from < 0 ? (delta > 0 ? -1 : count) : from;
do target = (((target + delta) % count) + count) % count;
while (props.options[target].disabled);
}
if (target !== from) choose(target);
// Focus moves at once, as a radio group's does; the option is already there.
items[target]?.focus();
}
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
function onKey(event) {
if (props.keyboard === 'none') return;
// A radio group moves from the option holding focus, `arrows` from the
// selection.
const from =
props.keyboard === 'radio'
? items.indexOf(event.target)
: selectedIndex.value;
if (STEPS[event.key]) move({ delta: STEPS[event.key], from });
else if (props.keyboard === 'radio' && event.key === 'Home')
move({ to: 'first', from });
else if (props.keyboard === 'radio' && event.key === 'End')
move({ to: 'last', from });
else return;
event.preventDefault();
}
</script>
<template>
<div
role="radiogroup"
class="ui-segmented gev-segmented"
:class="{ 'ui-segmented--stacked': props.stacked }"
:aria-label="props.label"
:aria-disabled="props.disabled ? 'true' : undefined"
@keydown="onKey"
>
<button
v-for="(option, index) in props.options"
:key="option.value"
v-bind="option.attrs"
:ref="(el) => (items[index] = el)"
type="button"
role="radio"
class="ui-segmented__item ui-focus gev-segment"
:class="[
props.itemClass,
{
'ui-segmented__item--on': option.value === props.modelValue,
[props.activeClass]: option.value === props.modelValue,
},
]"
:aria-checked="String(option.value === props.modelValue)"
:disabled="optionOff(option)"
:tabindex="tabindex(index)"
@click="choose(index)"
>{{ option.label }}</button
>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
import { h, mergeProps, nextTick, withDirectives } from 'vue';
import { vFieldValue } from '../rail/fieldValue.js';
import { vRangeValue } from '../rail/rangeValue.js';
// A null model leaves the select as it is.
const live = (el, binding) => {
if (binding.value != null) vRangeValue.updated(el, binding);
};
const LIVE = { mounted: live, updated: live };
/** Put the model back on the select. */
function takeModel(select, props) {
const next = props.modelValue;
if (next != null && select.value !== String(next))
select.value = String(next);
}
/**
* Core's select, with v-model, options and disabled. Core renders the bare select: its
* labels sit outside it or come as an `aria-label` attribute, so the attribute
* pass translates them. Options come from `options` ({ value, label }) or,
* where they carry their own attributes, the default slot. A change emits the
* chosen value; a caller's own `@change` still reaches the select.
*
* The select takes the model's value only when it differs (`v-range-value`),
* after its options are in place; a null model leaves it as it is. With
* `hold`, it never takes it while focused (`v-field-value`), and takes it back
* when it loses focus. Without `hold`, it takes the model back once the
* change's render has run: an owner that refuses a pick republishes the same
* value, which renders nothing, so this puts the refused pick back. The
* owners of these selects answer synchronously. Like UiSlider it is a functional render-function
* component. Not mirrored yet: `label` and `hideLabel`.
*/
export default function UiSelect(props, { attrs, emit, slots }) {
const options = props.options
? props.options.map((option) =>
h('option', { key: option.value, value: option.value }, option.label),
)
: slots.default?.();
const select = h(
'select',
mergeProps(attrs, {
disabled: props.disabled,
onChange: (event) => {
const select = event.target;
emit('update:modelValue', select.value);
if (!props.hold) void nextTick(() => takeModel(select, props));
},
onBlur: props.hold
? (event) => takeModel(event.target, props)
: undefined,
}),
options,
);
return withDirectives(select, [
[props.hold ? vFieldValue : LIVE, props.modelValue],
]);
}
UiSelect.props = {
modelValue: { type: [String, Number], default: undefined },
options: { type: Array, default: undefined },
disabled: { type: Boolean, default: false },
hold: { type: Boolean, default: false },
};
UiSelect.emits = ['update:modelValue'];
UiSelect.inheritAttrs = false;
+132
View File
@@ -0,0 +1,132 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let vue;
let UiSelect;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
vue = await import('vue');
UiSelect = (await import('./UiSelect.js')).default;
});
after(() => uninstall());
const OPTIONS = [
{ value: 'a', label: 'Alpha' },
{ value: 'b', label: 'Bravo' },
{ value: 'c', label: 'Charlie' },
];
function mountSelect(props = {}, attrs = {}) {
return mount(UiSelect, {
attachTo: document.body,
props: { options: OPTIONS, modelValue: 'b', ...props },
attrs,
});
}
test('a bare select with the caller’s attributes and the model chosen', () => {
const wrapper = mountSelect(
{},
{ id: 'pick', class: 'gev-field', 'aria-label': 'Pick' },
);
const select = wrapper.get('select');
assert.equal(select.attributes('class'), 'gev-field');
assert.equal(select.attributes('id'), 'pick');
assert.equal(select.attributes('aria-label'), 'Pick');
assert.equal(select.attributes('value'), undefined);
assert.deepEqual(
[...select.element.options].map((o) => [o.value, o.textContent]),
[
['a', 'Alpha'],
['b', 'Bravo'],
['c', 'Charlie'],
],
);
assert.equal(select.element.value, 'b');
assert.equal(select.element.disabled, false);
wrapper.unmount();
});
test('a change emits the value, and a caller’s own change still runs', async () => {
const seen = [];
const wrapper = mountSelect(
{},
{ onChange: (event) => seen.push(event.target.value) },
);
const select = wrapper.get('select');
select.element.value = 'c';
await select.trigger('change');
assert.deepEqual(wrapper.emitted('update:modelValue'), [['c']]);
assert.deepEqual(seen, ['c']);
wrapper.unmount();
});
test('a refused pick goes back to the model', async () => {
const { nextTick } = vue;
const wrapper = mountSelect();
const select = wrapper.get('select');
select.element.value = 'c';
await select.trigger('change');
await nextTick();
assert.equal(select.element.value, 'b');
wrapper.unmount();
});
test('the model picks the option, and a null model leaves the select', async () => {
const wrapper = mountSelect();
const select = wrapper.get('select').element;
await wrapper.setProps({ modelValue: 'c' });
assert.equal(select.value, 'c');
select.value = 'a';
await wrapper.setProps({ modelValue: null, disabled: true });
assert.equal(select.value, 'a');
assert.equal(select.disabled, true);
wrapper.unmount();
});
test('a held select keeps a focused pick and takes the model back on blur', async () => {
const wrapper = mountSelect({ hold: true });
const select = wrapper.get('select').element;
select.focus();
select.value = 'a';
await wrapper.setProps({ modelValue: 'c' });
assert.equal(select.value, 'a');
select.blur();
await wrapper.get('select').trigger('blur');
assert.equal(select.value, 'c');
wrapper.unmount();
});
test('slotted options render, and the model applies once they arrive', async () => {
const { defineComponent, h, ref, nextTick } = vue;
const options = ref(['x']);
const model = ref('x');
const wrapper = mount(
defineComponent({
setup: () => () =>
h(
UiSelect,
{ modelValue: model.value },
{
default: () =>
options.value.map((value) =>
h('option', { key: value, value, 'data-i18n': '' }, value),
),
},
),
}),
{ attachTo: document.body },
);
const select = wrapper.get('select').element;
assert.equal(select.options[0].hasAttribute('data-i18n'), true);
options.value = ['x', 'y'];
model.value = 'y';
await nextTick();
assert.equal(select.options.length, 2);
assert.equal(select.value, 'y');
wrapper.unmount();
});
+77
View File
@@ -0,0 +1,77 @@
import { h, mergeProps, normalizeClass, withDirectives } from 'vue';
import { vFieldValue } from '../rail/fieldValue.js';
import { vRangeValue } from '../rail/rangeValue.js';
// Server-rendered markup carries the model as the input's value.
const ssrValue = ({ value }) => (value == null ? {} : { value });
// A null model leaves the input as it is.
const live = (el, binding) => {
if (binding.value != null) vRangeValue.updated(el, binding);
};
const LIVE = { mounted: live, updated: live, getSSRProps: ssrValue };
const HOLD = { ...vFieldValue, getSSRProps: ssrValue };
/** Put the model back on a held input that lost focus. */
function takeModel(input, props) {
const next = props.modelValue;
if (next != null && input.value !== String(next)) input.value = String(next);
}
/**
* Core's slider, with v-model, min, max, step and disabled. Core keeps the
* native range input (its keyboard, form and styling) rather than drawing a
* `role="slider"` track. The input carries Core's `gev-quantitative-slider` class ahead of
* the caller's (`unstyled` leaves it off, for a range drawn as the browser's
* own), the caller's attributes come first, as in the markup this replaced,
* and the accessible name comes as an `aria-label` attribute so the attribute
* pass translates it. The value emits as a number, and a caller's own input,
* change or blur handler still reaches the input.
*
* The input takes the model's value only when it differs (`v-range-value`),
* and a null model leaves it as it is;
* with `hold`, never while it is focused (`v-field-value`), so a render
* cannot move a handle a person is dragging, and it takes the model back
* when it loses focus: a model left unchanged renders nothing. Owners take
* every value their slider can reach, some only after a delay, so an input
* is never put back on its own. It is a functional component written as a
* render function, so portable entries need no component compiler. Not
* mirrored yet: `label`, `format`, `markers` and `endLabels`.
*/
export default function UiSlider(props, { attrs, emit }) {
const { class: className, ...rest } = attrs;
const classes = normalizeClass([
props.unstyled ? null : 'gev-quantitative-slider',
className,
]);
return withDirectives(
h(
'input',
mergeProps(rest, {
class: classes || undefined,
type: 'range',
min: props.min,
max: props.max,
step: props.step,
disabled: props.disabled,
onInput: (event) =>
emit('update:modelValue', Number(event.target.value)),
onBlur: props.hold
? (event) => takeModel(event.target, props)
: undefined,
}),
),
[[props.hold ? HOLD : LIVE, props.modelValue]],
);
}
UiSlider.props = {
modelValue: { type: [Number, String], default: undefined },
min: { type: [Number, String], required: true },
max: { type: [Number, String], required: true },
step: { type: [Number, String], default: undefined },
disabled: { type: Boolean, default: false },
hold: { type: Boolean, default: false },
unstyled: { type: Boolean, default: false },
};
UiSlider.emits = ['update:modelValue'];
UiSlider.inheritAttrs = false;
+139
View File
@@ -0,0 +1,139 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../../testSupport/vueDom.mjs';
let mount;
let UiSlider;
let uninstall;
before(async () => {
uninstall = installVueDom();
({ mount } = await import('@vue/test-utils'));
UiSlider = (await import('./UiSlider.js')).default;
});
after(() => uninstall());
function mountSlider(props = {}, attrs = {}) {
return mount(UiSlider, {
attachTo: document.body,
props: { min: 0, max: 100, modelValue: 40, ...props },
attrs,
});
}
test('a native range input with Core’s class beside the caller’s', () => {
const wrapper = mountSlider(
{ step: 5 },
{ id: 'vol', class: 'pp-slider', 'aria-label': 'Volume' },
);
const input = wrapper.get('input');
assert.equal(input.attributes('type'), 'range');
assert.equal(input.attributes('class'), 'gev-quantitative-slider pp-slider');
assert.equal(input.attributes('id'), 'vol');
assert.equal(input.attributes('aria-label'), 'Volume');
assert.equal(input.attributes('step'), '5');
assert.equal(input.attributes('value'), undefined);
assert.equal(input.element.value, '40');
wrapper.unmount();
});
test('no step attribute unless one is given, and disabled reaches the input', () => {
const wrapper = mountSlider({ disabled: true });
const input = wrapper.get('input');
assert.equal(input.attributes('step'), undefined);
assert.equal(input.element.disabled, true);
wrapper.unmount();
});
test('a drag emits the value as a number', async () => {
const wrapper = mountSlider({ min: 0.25, max: 4, step: 0.25, modelValue: 1 });
const input = wrapper.get('input');
input.element.value = '2.5';
await input.trigger('input');
assert.deepEqual(wrapper.emitted('update:modelValue'), [[2.5]]);
wrapper.unmount();
});
test('the model moves the handle, even while focused, unless it holds', async () => {
const live = mountSlider();
live.element.focus();
await live.setProps({ modelValue: 70 });
assert.equal(live.element.value, '70');
live.unmount();
const held = mountSlider({ hold: true });
held.element.focus();
held.element.value = '55';
await held.setProps({ modelValue: 70 });
assert.equal(held.element.value, '55');
held.element.blur();
await held.setProps({ modelValue: 80 });
assert.equal(held.element.value, '80');
held.unmount();
});
test('a held slider with no model keeps its value', async () => {
const wrapper = mountSlider({ hold: true, modelValue: 30 });
await wrapper.setProps({ modelValue: null });
assert.equal(wrapper.element.value, '30');
wrapper.unmount();
});
test('a held handle takes the model back when it loses focus', async () => {
const wrapper = mountSlider({ hold: true, modelValue: 60 });
const input = wrapper.get('input');
input.element.focus();
input.element.value = '35';
await input.trigger('input');
await wrapper.setProps({ modelValue: 60 });
assert.equal(input.element.value, '35');
input.element.blur();
await input.trigger('blur');
assert.equal(input.element.value, '60');
wrapper.unmount();
});
test('an input stays until the owner answers, however late', async () => {
for (const hold of [false, true]) {
const wrapper = mountSlider({ hold, modelValue: 40 });
const input = wrapper.get('input');
input.element.value = '70';
await input.trigger('input');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(input.element.value, '70', `hold: ${hold}`);
await wrapper.setProps({ modelValue: 70 });
assert.equal(input.element.value, '70', `hold: ${hold}`);
wrapper.unmount();
}
});
test('a caller’s own input, change and blur handlers still run', async () => {
const seen = [];
const wrapper = mountSlider(
{},
{
onInput: (event) => seen.push(['input', event.target.value]),
onChange: () => seen.push(['change']),
onBlur: () => seen.push(['blur']),
},
);
const input = wrapper.get('input');
input.element.value = '55';
await input.trigger('input');
await input.trigger('change');
await input.trigger('blur');
assert.deepEqual(seen, [['input', '55'], ['change'], ['blur']]);
assert.deepEqual(wrapper.emitted('update:modelValue'), [[55]]);
wrapper.unmount();
});
test('a null model leaves the input, and unstyled leaves Core’s class off', async () => {
const wrapper = mountSlider({ modelValue: null, unstyled: true });
const input = wrapper.get('input');
assert.equal(input.attributes('class'), undefined);
input.element.value = '12';
await wrapper.setProps({ disabled: true });
assert.equal(input.element.value, '12');
await wrapper.setProps({ unstyled: false });
assert.equal(input.attributes('class'), 'gev-quantitative-slider');
wrapper.unmount();
});
+50
View File
@@ -0,0 +1,50 @@
<script setup>
import { onBeforeUnmount, ref } from 'vue';
const props = defineProps({
text: { type: String, required: true },
/** Which side of the trigger the tip opens on. */
side: { type: String, default: 'right' },
disabled: { type: Boolean, default: false },
/** Milliseconds the pointer rests before the tip shows. */
delay: { type: Number, default: 0 },
});
const open = ref(false);
let timer = null;
function show() {
if (props.disabled) return;
clearTimeout(timer);
if (props.delay > 0)
timer = setTimeout(() => (open.value = true), props.delay);
else open.value = true;
}
function hide() {
clearTimeout(timer);
timer = null;
open.value = false;
}
onBeforeUnmount(hide);
</script>
<template>
<span
class="ui-tooltip"
@pointerenter="show"
@pointerleave="hide"
@focusin="show"
@focusout="hide"
>
<slot />
<span
v-if="open && !disabled"
class="ui-tooltip__tip"
:class="`ui-tooltip__tip--${side}`"
role="tooltip"
>{{ text }}</span
>
</span>
</template>
+12
View File
@@ -0,0 +1,12 @@
/**
* @typedef {object} UiRailItem
* @property {string} id Item identity, emitted on selection.
* @property {string} label Translated name, shown as the tooltip and accessible name.
* @property {string} icon Material Symbols glyph.
* @property {boolean} [disabled] Shown but not selectable.
* @property {boolean} [separatorBefore] Draw a divider above this item.
* @property {string|number} [badge] Small count or marker on the icon.
*/
/** How long the pointer rests on an item before its tip shows over an open panel. */
export const RAIL_PANEL_OPEN_TOOLTIP_DELAY_MS = 400;
+268 -405
View File
@@ -1,5 +1,5 @@
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { h, render as renderVue } from 'vue';
import LayerRows from './layerPanel/LayerRows.vue';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
@@ -16,6 +16,7 @@ import {
formatNumber,
getLocale,
msg,
onLocaleChange,
t,
translateKnown,
} from '../i18n/index.js';
@@ -134,65 +135,108 @@ export function layerKeyRequirementTooltip(layer = {}) {
);
}
const ROW_TEXT_SEPARATOR = ' · ';
function formatCount(n) {
if (n >= 1000)
return `${formatNumber(n / 1000, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
useGrouping: false,
})}K`;
return String(n);
}
// A short Latin code joined by hyphens: every part after a hyphen is capitals
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
// in an error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
const HYPHENATED_CODE_MAX = 16;
/** A span for one run of row text; `keep` stops the browser breaking inside it. */
function rowTextSpan(text, keep) {
const span = document.createElement('span');
if (keep) span.className = 'data-keep';
span.textContent = text;
return span;
/** Qualify a loaded count when it does not mean items currently on screen. */
function layerCountText(stats) {
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
return stats.countLabel;
return stats.count ? formatCount(stats.count) : '—';
}
/**
* Set a row's name, count or meta line so that a narrow column wraps it well
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
* splits at its hyphen, and a " · " separator stays at the end of the line it
* closes, so no line starts with "·". The element's textContent is still the
* plain string; text with neither feature is set directly.
*
* @param {HTMLElement} element
* @param {string} text
* A row's count. Outside English a count phrase ("24 nearby · 2 on screen")
* gets its own line (`countLine`), or it squeezes a longer translated name to
* a word per line. "—" is marked `empty` so a wide status chip can replace it
* (radio.css).
* @param {object} stats Layer stats.
* @returns {{text: string, empty: boolean, countLine: boolean}}
*/
export function setRowText(element, text) {
const value = String(text ?? '');
// Refreshes run every second; leave unchanged text (and its spans) alone.
if (element.textContent === value) return;
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
(match) => match[0].length <= HYPHENATED_CODE_MAX,
);
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) {
element.textContent = value;
return;
}
const spans = [];
const pushPlain = (from, to) => {
if (to <= from) return;
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
pieces.forEach((piece, index) => {
// The space before the dot belongs to a no-wrap span, so the only break
// opportunity around a separator is the space after it.
if (index > 0) spans.push(rowTextSpan(' ·', true));
const textPiece = index > 0 ? ` ${piece}` : piece;
if (textPiece) spans.push(rowTextSpan(textPiece, false));
});
export function presentLayerCount(stats = {}) {
const text = layerCountText(stats);
return {
text,
empty: text === '—',
countLine:
getLocale() !== 'en' &&
typeof stats.countLabel === 'string' &&
/\d/.test(stats.countLabel),
};
}
/**
* A row's lifecycle toggle: its classes, feed state, label and accessible
* name. A busy or unavailable toggle is `ariaDisabled` and stays focusable;
* native `disabled` would evict keyboard focus in Chromium.
* @param {object} layer Row from the layer manager's getAll().
*/
export function presentLayerToggle(layer) {
const unavailable = layer.stats?.sourceUnavailable === true;
const feedState = unavailable
? 'unavailable'
: layer.enabled
? layerFeedState(layer.stats)
: 'off';
const transitioning =
layer.lifecycleState === 'enabling' || layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
const classes = {
active: Boolean(layer.enabled),
transitioning,
enabling: layer.lifecycleState === 'enabling',
disabling: layer.lifecycleState === 'disabling',
'lifecycle-uncertain': uncertain,
};
for (const state of Object.keys(FEED_STATE_LABEL_KEYS))
classes[`feed-${state}`] =
(layer.enabled || unavailable) && !uncertain && feedState === state;
const showsFeedState =
!transitioning && !uncertain && (layer.enabled || unavailable);
const stateText = transitioning
? lifecycleStateLabel(layer.lifecycleState)
: uncertain
? t('UNCERTAIN')
: showsFeedState
? t(FEED_STATE_LABEL_KEYS[feedState])
: t('OFF');
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
const keyGuidance = unavailable
? String(
translateFeedError(layer.stats.error) || t('Data source unavailable'),
)
: layerKeyRequirementTooltip(layer);
const ariaLabel = t('{name}: {state}', {
name: panelLabel(layer),
state: stateText,
});
return {
classes,
feedState: transitioning
? layer.lifecycleState
: uncertain
? 'uncertain'
: feedState,
ariaDisabled: transitioning || (unavailable && !layer.enabled),
ariaBusy: transitioning,
// The chip may abbreviate where a language's word is wider than the
// status column; the meta line and the accessible name keep the full word.
text:
showsFeedState && feedState === 'unavailable'
? t('UNAVAILABLE', { context: 'status chip' })
: stateText,
title: keyGuidance,
ariaLabel: keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
};
let cursor = 0;
for (const match of codes) {
pushPlain(cursor, match.index);
spans.push(rowTextSpan(match[0], true));
cursor = match.index + match[0].length;
}
pushPlain(cursor, value.length);
element.textContent = '';
for (const span of spans) element.appendChild(span);
}
/** Layer row presentation over supplied state and actions; no layer imports. */
@@ -223,10 +267,17 @@ export class LayerPanel {
this._cancelRowControlsRefresh = null;
this._recentImageryFactory = null;
this._recentImageryPanel = null;
this._rows = [];
this._rowViews = new Map();
this._pending = new Set();
}
mount(container) {
if (this._destroyed) return;
this._releaseBindings();
if (this._toggleContainer && this._toggleContainer !== container)
renderVue(null, this._toggleContainer);
// The rows replace whatever the container held before.
if (container && !container._vnode) container.replaceChildren();
this._toggleContainer = container;
this._weatherPanel?.destroy();
this._weatherPanel = createWeatherPanel({
@@ -258,10 +309,6 @@ export class LayerPanel {
if (container && this._recentImageryFactory)
this._recentImageryPanel = this._recentImageryFactory(container) || null;
}
_bind(element, type, listener) {
element.addEventListener(type, listener);
this._removers.push(() => element.removeEventListener(type, listener));
}
_releaseBindings() {
this._generation++;
this._cancelRowControlsRefresh?.();
@@ -277,14 +324,15 @@ export class LayerPanel {
this._recentImageryPanel?.destroy();
this._recentImageryPanel = null;
this._recentImageryFactory = null;
if (this._toggleContainer) renderVue(null, this._toggleContainer);
this._toggleContainer = null;
}
_renderToggles() {
if (this._destroyed || !this._toggleContainer) return;
this._releaseBindings();
this._toggleContainer.innerHTML = '';
const generation = this._generation;
// The rows are fixed here, in panel order under their group headings;
// refreshes repaint them. A layer whose row is a rail readout keeps only
// its toggle and metadata.
const layers = this.getAll()
.slice()
.sort(
@@ -292,93 +340,20 @@ export class LayerPanel {
(PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) -
(PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length),
);
this._rows = [];
this._rowViews = new Map();
let previousGroup = '';
for (const layer of layers) {
if (!layer.showInTogglePanel) continue;
const group =
PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ??
msg('Other layers');
if (group && group !== previousGroup) {
const heading = document.createElement('h3');
heading.className = 'data-layer-group-heading';
heading.textContent = t(group);
this._toggleContainer.appendChild(heading);
}
if (group && group !== previousGroup)
this._rows.push({ key: `group:${group}`, heading: group });
previousGroup = group;
const row = document.createElement('div');
row.className = 'data-toggle-row';
row.dataset.layerId = layer.id;
// English name for voice tools, which must not receive translated text.
row.dataset.layerName = englishPanelLabel(layer);
const topRow = document.createElement('div');
topRow.className = 'data-toggle-top';
const left = document.createElement('div');
left.className = 'data-toggle-left';
const icon = document.createElement('span');
icon.className = 'data-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = layer.icon;
const name = document.createElement('span');
name.className = 'data-name';
setRowText(name, panelLabel(layer));
left.appendChild(icon);
left.appendChild(name);
const right = document.createElement('div');
right.className = 'data-toggle-right';
const count = document.createElement('span');
count.className = 'data-count';
this._syncCount(row, count, layer.stats);
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = `data-toggle-btn${layer.enabled ? ' active' : ''}`;
this._syncToggleButton(toggle, layer);
this._bind(toggle, 'click', async () => {
// Native `disabled` immediately evicts keyboard focus in Chromium. Keep
// the lifecycle control focusable while it is busy, and enforce the
// same single-flight interaction contract through ARIA instead.
if (
this._destroyed ||
this._generation !== generation ||
toggle.getAttribute('aria-disabled') === 'true'
)
return;
toggle.setAttribute('aria-disabled', 'true');
toggle.setAttribute('aria-busy', 'true');
try {
await this.setEnabled(layer.id, !this.isEnabled(layer.id), {
origin: 'user',
});
} catch (error) {
console.warn(`[Data] ${layer.id} toggle error:`, error);
} finally {
const current = this.getAll().find(({ id }) => id === layer.id);
if (!this._destroyed && current && this._generation === generation)
this._syncToggleButton(toggle, current);
}
});
const readout = Boolean(this._rowControlsFor(layer.id)?.readout);
if (!readout) right.appendChild(count);
right.appendChild(toggle);
topRow.appendChild(left);
topRow.appendChild(right);
const bottomRow = document.createElement('div');
bottomRow.className = 'data-toggle-meta';
setRowText(bottomRow, this._buildMetaText(layer));
row.appendChild(topRow);
row.appendChild(bottomRow);
// Optional per-layer sub-controls (chips + color legend). The click
// listener is delegated and attached once here, so it survives
// _refreshTogglePanel — which only rewrites the container's contents.
if (this.hasRowControls(layer.id)) {
const controls = this.hasRowControls(layer.id);
if (controls) {
// A layer whose controls settle asynchronously (a chunked catalog load
// that can also fail) pushes a re-render through this; nothing else
// would repaint the row before its next scheduled refresh.
@@ -386,48 +361,157 @@ export class LayerPanel {
this._scheduleRowControlsRefresh(),
);
if (unsubscribe) this._removers.push(unsubscribe);
if (!readout) {
const controls = document.createElement('div');
controls.className = 'data-toggle-controls';
this._bind(controls, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Re-read the live descriptor rather than trusting the rendered
// chip, so a stale row can never apply an inverted toggle.
const chip = this._rowControlsFor(layer.id)?.chips?.find(
(entry) => entry.id === button.dataset.chipId,
);
if (!chip || chip.disabled || !this.isEnabled(layer.id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(layer.id, chip.params, { origin: 'user' });
});
row.appendChild(controls);
// An ordered list below the chips, for a layer whose row carries a
// sequence (turn-by-turn directions). Its own delegated listener, its
// own container — the chip row stays a chip row.
const list = document.createElement('ol');
list.className = 'data-row-list';
list.hidden = true;
this._bind(list, 'click', (event) => {
const button = event.target?.closest?.('.data-row-list-item');
if (!button || button.disabled) return;
const item = this._rowControlsFor(layer.id)?.list?.items?.find(
(entry) => entry.id === button.dataset.listItemId,
);
if (item?.params)
this.setLayerParams(layer.id, item.params, { origin: 'user' });
});
row.appendChild(list);
this._syncRowControls(controls, layer, list);
}
}
this._toggleContainer.appendChild(row);
this._rows.push({
key: layer.id,
id: layer.id,
count: !readout,
controls: controls && !readout,
info: false,
});
}
// The rows carry text this panel translates; show it in a new locale.
this._removers.push(
onLocaleChange(() => {
this._paint();
this._refreshWeatherPanel();
}),
);
this._paint();
this._refreshWeatherPanel();
}
/** Present every row from the layers' current state and render them. */
_paint() {
if (this._destroyed || !this._toggleContainer) return;
const layers = new Map(this.getAll().map((layer) => [layer.id, layer]));
const items = this._rows.map((row) => {
if (row.heading) return { key: row.key, heading: t(row.heading) };
const layer = layers.get(row.id);
if (layer) this._rowViews.set(row.id, this._presentRow(row, layer));
return this._rowViews.get(row.id);
});
const generation = this._generation;
renderVue(
h(LayerRows, {
items: items.filter(Boolean),
onToggle: (id) => this._toggle(id, generation),
onChip: (id, chipId) => this._selectChip(id, chipId, generation),
onListItem: (id, itemId) =>
this._selectListItem(id, itemId, generation),
}),
this._toggleContainer,
);
}
_presentRow(row, layer) {
const toggle = presentLayerToggle(layer);
if (this._pending.has(layer.id)) {
// A busy toggle keeps keyboard focus: `aria-disabled` and the click
// guard prevent repeat activation, where native `disabled` would evict
// focus in Chromium.
toggle.ariaDisabled = true;
toggle.ariaBusy = true;
}
return {
key: layer.id,
id: layer.id,
// English name for voice tools, which must not receive translated text.
englishName: englishPanelLabel(layer),
icon: layer.icon,
name: panelLabel(layer),
count: row.count ? presentLayerCount(layer.stats) : null,
toggle,
meta: this._buildMetaText(layer),
controls: row.controls ? this._presentControls(row, layer) : null,
};
}
/**
* A layer's row chips, color legend, info line and ordered list. The block
* is hidden while the layer is off (or while a dependency owner has
* surrendered it), so a quiet row stays quiet. Chips and list items are
* keyed, so the refresh a chip's own click triggers keeps keyboard focus.
* It records on `row` what persists across paints: a readout drops the
* controls for good, and a shown info line stays.
*/
_presentControls(row, layer) {
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
if (controls?.readout) {
// The row became a rail readout: its controls go for good.
row.controls = false;
return null;
}
const chips = controls?.chips || [];
const legend = (controls?.legend || []).map((item) => ({
color: item.color,
blurb: item.blurb,
text:
item.count == null
? String(item.label)
: `${item.label} ${formatCount(item.count)}`,
}));
const infoText = controls?.info ? String(controls.info) : '';
// The info line, once shown, stays in place and hides when empty.
if (infoText) row.info = true;
return {
hidden: chips.length === 0 && legend.length === 0 && !infoText,
chips,
legend,
info: row.info
? { text: infoText, title: controls?.infoTitle || '' }
: null,
list: controls?.list || null,
};
}
async _toggle(id, generation) {
const layer = this.getAll().find((entry) => entry.id === id);
if (
this._destroyed ||
this._generation !== generation ||
!layer ||
this._pending.has(id) ||
presentLayerToggle(layer).ariaDisabled
)
return;
this._pending.add(id);
this._paint();
try {
await this.setEnabled(id, !this.isEnabled(id), { origin: 'user' });
} catch (error) {
console.warn(`[Data] ${id} toggle error:`, error);
} finally {
this._pending.delete(id);
// Repaint even after a remount: the new rows were painted busy while
// this toggle was pending.
if (!this._destroyed) this._paint();
}
}
// Re-read the live descriptor rather than trusting the rendered chip, so a
// stale row can never apply an inverted toggle. Unmounted rows keep their
// listeners, so a click from a released generation is dropped.
_selectChip(id, chipId, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const chip = this._rowControlsFor(id)?.chips?.find(
(entry) => entry.id === chipId,
);
if (!chip || chip.disabled || !this.isEnabled(id)) return;
if (typeof chip.onClick === 'function') chip.onClick();
else if (chip.params)
this.setLayerParams(id, chip.params, { origin: 'user' });
}
_selectListItem(id, itemId, generation = this._generation) {
if (this._destroyed || this._generation !== generation) return;
const item = this._rowControlsFor(id)?.list?.items?.find(
(entry) => entry.id === itemId,
);
if (item && !item.disabled && item.params)
this.setLayerParams(id, item.params, { origin: 'user' });
}
_scheduleRowControlsRefresh() {
if (this._destroyed || this._cancelRowControlsRefresh) return;
const refresh = () => {
@@ -474,125 +558,6 @@ export class LayerPanel {
);
}
/**
* Write a row's count. Outside English a count phrase ("24 nearby · 2 on
* screen") gets its own line, or it squeezes a longer translated name to a
* word per line. "—" is marked so a wide status chip can replace it
* (radio.css).
*/
_syncCount(row, count, stats) {
const text = this._layerCountText(stats);
setRowText(count, text);
const phrase =
getLocale() !== 'en' &&
typeof stats.countLabel === 'string' &&
/\d/.test(stats.countLabel);
row.classList.toggle('data-toggle-row--count-line', phrase);
count.classList.toggle('data-count-empty', text === '—');
}
/** Qualify a loaded count when it does not mean items currently on screen. */
_layerCountText(stats) {
if (typeof stats.countLabel === 'string' && stats.countLabel.trim())
return stats.countLabel;
return stats.count ? this._formatCount(stats.count) : '—';
}
/**
* Render a layer's row chips and color legend, and keep the whole block
* hidden while the layer is off (or while a dependency owner has surrendered
* it) so a quiet row stays quiet.
*
* Chip BUTTONS are reconciled in place, keyed by chip id, rather than
* rebuilt: this runs on every panel refresh — including the one the chip's
* own click triggers — and replacing the node would drop keyboard focus
* mid-interaction. Legend entries are rebuilt only when their content changes;
* the info node is retained across refreshes.
* @param {HTMLElement|null} container The row's `.data-toggle-controls` node.
* @param {object} layer Registered layer entry.
* @param {HTMLElement|null} [listContainer] The row's `.data-row-list` node.
*/
_syncRowControls(container, layer, listContainer = null) {
if (!container) return;
const controls = layer.enabled ? this._rowControlsFor(layer.id) : null;
if (controls?.readout) {
container.remove();
listContainer?.remove();
return;
}
const chips = controls?.chips || [];
const legend = controls?.legend || [];
const infoText = controls?.info;
this._syncRowList(listContainer, controls?.list || null);
container.hidden = chips.length === 0 && legend.length === 0 && !infoText;
const info = container._rowControlsInfo || null;
const firstLegend = container.querySelector('.data-toggle-legend-item');
syncChipGroup(container, chips, { before: firstLegend || info });
const legendSignature = JSON.stringify(
legend.map(({ label, color, count, blurb }) => [
label,
color,
count,
blurb,
]),
);
if (container._legendSignature !== legendSignature) {
for (const node of [...container.children]) {
if (node.className === 'data-toggle-legend-item') node.remove();
}
for (const item of legend) {
const entry = document.createElement('span');
entry.className = 'data-toggle-legend-item';
if (item.blurb) entry.title = item.blurb;
const swatch = document.createElement('span');
swatch.className = 'data-toggle-legend-swatch';
swatch.style.background = item.color;
const text = document.createElement('span');
text.textContent =
item.count == null
? String(item.label)
: `${item.label} ${this._formatCount(item.count)}`;
entry.append(swatch, text);
container.insertBefore(entry, info);
}
container._legendSignature = legendSignature;
}
if (infoText || info) {
const node = info || document.createElement('div');
if (!info) {
container.appendChild(node);
container._rowControlsInfo = node;
}
const className = 'data-toggle-controls-info';
if (node.className !== className) node.className = className;
const text = infoText ? String(infoText) : '';
const title = controls?.infoTitle || '';
if (node.textContent !== text) node.textContent = text;
if (node.title !== title) node.title = title;
const hidden = !text;
if (node.hidden !== hidden) node.hidden = hidden;
}
}
/**
* Render a row's ordered list (turn-by-turn directions).
*
* Each entry is a real `<button>` inside a real `<li>`, so Tab reaches it and
* Enter activates it with no key handling of our own, and the `<ol>` carries
* the ordering a screen reader announces. Items are reconciled in place,
* keyed by id, for the same reason chips are: this runs on every refresh —
* including the one a click on the list triggers — and replacing the node
* would drop keyboard focus mid-interaction.
* @param {HTMLElement|null} container The row's `.data-row-list` node.
* @param {{ariaLabel?: string, items?: Array<object>}|null} list Descriptor.
*/
_syncRowList(container, list) {
syncRowList(container, list);
}
_refreshTogglePanel() {
if (this._destroyed || !this._toggleContainer) return;
// Skip DOM churn while hidden; visibilitychange (main.js) triggers one
@@ -601,33 +566,7 @@ export class LayerPanel {
this.onHiddenRefresh();
return;
}
for (const layer of this.getAll()) {
const row = this._toggleContainer.querySelector(
`[data-layer-id="${layer.id}"]`,
);
if (!row) continue;
const btn = row.querySelector('.data-toggle-btn');
if (btn) {
this._syncToggleButton(btn, layer);
}
const count = row.querySelector('.data-count');
if (count) {
this._syncCount(row, count, layer.stats);
}
const meta = row.querySelector('.data-toggle-meta');
if (meta) {
setRowText(meta, this._buildMetaText(layer));
}
this._syncRowControls(
row.querySelector('.data-toggle-controls'),
layer,
row.querySelector('.data-row-list'),
);
}
this._paint();
this._refreshWeatherPanel();
}
@@ -635,7 +574,11 @@ export class LayerPanel {
const stats = layer.stats || {};
const feedState = layerFeedState(stats);
const stateLabel = t(FEED_STATE_LABEL_KEYS[feedState]);
const source = sourceLabel(stats.source || layer.source);
// A layer marks source text that is data rather than a catalog name.
const source =
stats.sourceVerbatim === true && stats.source
? stats.source
: sourceLabel(stats.source || layer.source);
const lifecycleState =
layer.lifecycleState || (layer.enabled ? 'enabled' : 'disabled');
if (lifecycleState === 'enabling' || lifecycleState === 'disabling') {
@@ -745,86 +688,6 @@ export class LayerPanel {
return t('{source} · {ago}', { source, ago });
}
_syncToggleButton(button, layer) {
const unavailable = layer.stats?.sourceUnavailable === true;
const feedState = unavailable
? 'unavailable'
: layer.enabled
? layerFeedState(layer.stats)
: 'off';
const transitioning =
layer.lifecycleState === 'enabling' ||
layer.lifecycleState === 'disabling';
const uncertain = Boolean(layer.lifecycleUncertain);
button.classList.toggle('active', layer.enabled);
button.classList.toggle('transitioning', transitioning);
button.classList.toggle('enabling', layer.lifecycleState === 'enabling');
button.classList.toggle('disabling', layer.lifecycleState === 'disabling');
button.classList.toggle('lifecycle-uncertain', uncertain);
for (const state of Object.keys(FEED_STATE_LABEL_KEYS)) {
button.classList.toggle(
`feed-${state}`,
(layer.enabled || unavailable) && !uncertain && feedState === state,
);
}
button.dataset.feedState = transitioning
? layer.lifecycleState
: uncertain
? 'uncertain'
: feedState;
// A busy toggle remains the keyboard focus owner. `aria-disabled` plus the
// click guard above prevents repeat activation without the focus loss caused
// by native `disabled`.
button.disabled = false;
button.setAttribute(
'aria-disabled',
String(transitioning || (unavailable && !layer.enabled)),
);
button.setAttribute('aria-busy', String(transitioning));
const showsFeedState =
!transitioning && !uncertain && (layer.enabled || unavailable);
const stateText = transitioning
? lifecycleStateLabel(layer.lifecycleState)
: uncertain
? t('UNCERTAIN')
: showsFeedState
? t(FEED_STATE_LABEL_KEYS[feedState])
: t('OFF');
// The chip may abbreviate where a language's word is wider than the
// status column; the meta line and the accessible name keep the full word.
button.textContent =
showsFeedState && feedState === 'unavailable'
? t('UNAVAILABLE', { context: 'status chip' })
: stateText;
const keyGuidance = unavailable
? String(
translateFeedError(layer.stats.error) || t('Data source unavailable'),
)
: layerKeyRequirementTooltip(layer);
// Name the missing key on the control itself: a row reading KEY REQUIRED
// without saying WHICH key leaves a dead control and no next step. Empty
// when the layer needs no key, or already has one.
button.title = keyGuidance;
const ariaLabel = t('{name}: {state}', {
name: panelLabel(layer),
state: stateText,
});
button.setAttribute(
'aria-label',
keyGuidance ? `${ariaLabel}. ${keyGuidance}` : ariaLabel,
);
}
_formatCount(n) {
if (n >= 1000)
return `${formatNumber(n / 1000, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
useGrouping: false,
})}K`;
return String(n);
}
_timeAgo(timestamp) {
const diff = Math.floor((Date.now() - timestamp) / 1000);
if (diff < 5) return t('just now');
+68 -115
View File
@@ -1,3 +1,4 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -34,14 +35,8 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
});
test('partial feed controls distinguish incomplete records from stale data and outages', async () => {
const { LayerPanel, layerFeedState } = await import('./layerPanel.js');
const classes = new Map();
const attrs = new Map();
const button = {
classList: { toggle: (key, value) => classes.set(key, value) },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
const { LayerPanel, layerFeedState, presentLayerToggle } =
await import('./layerPanel.js');
const layer = {
id: 'ais-live-vessels',
name: 'Live Vessels',
@@ -57,12 +52,12 @@ test('partial feed controls distinguish incomplete records from stale data and o
},
};
const panel = LayerPanel.prototype;
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'PARTIAL');
assert.equal(button.dataset.feedState, 'partial');
assert.equal(classes.get('feed-partial'), true);
assert.equal(classes.get('feed-stale'), false);
assert.match(attrs.get('aria-label'), /PARTIAL/);
let toggle = presentLayerToggle(layer);
assert.equal(toggle.text, 'PARTIAL');
assert.equal(toggle.feedState, 'partial');
assert.equal(toggle.classes['feed-partial'], true);
assert.equal(toggle.classes['feed-stale'], false);
assert.match(toggle.ariaLabel, /PARTIAL/);
assert.match(
panel._buildMetaText(layer),
/^PARTIAL · AISStream · 2 of 3 records accepted · /,
@@ -85,12 +80,11 @@ test('partial feed controls distinguish incomplete records from stale data and o
);
assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading');
layer.stats = { ...layer.stats, partial: false };
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'ON');
assert.equal(classes.get('feed-partial'), false);
toggle = presentLayerToggle(layer);
assert.equal(toggle.text, 'ON');
assert.equal(toggle.classes['feed-partial'], false);
layer.enabled = false;
panel._syncToggleButton(button, layer);
assert.equal(button.textContent, 'OFF');
assert.equal(presentLayerToggle(layer).text, 'OFF');
});
test('a stale row dates cached data by its oldest tile, not the last load', async () => {
@@ -116,10 +110,9 @@ test('a stale row dates cached data by its oldest tile, not the last load', asyn
test('readout rows contain only toggles and metadata; ordinary rows retain controls', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
const f = railFixture();
const previousDocument = globalThis.document;
globalThis.document = f.document;
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const find = (id) => container.querySelector(`[data-layer-id="${id}"]`);
const ids = [
'wind',
'weather-radar',
@@ -160,9 +153,9 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
}),
});
try {
panel.mount(f.container);
panel.mount(container);
for (const id of ids.slice(0, -1)) {
const row = f.find((n) => n.dataset.layerId === id);
const row = find(id);
assert.equal(row.children.length, 2);
assert.ok(row.querySelector('.data-name'));
assert.ok(row.querySelector('.data-toggle-meta'));
@@ -173,14 +166,14 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
'data-toggle-controls-status',
'data-row-list',
])
assert.equal(row.querySelector(`.${cls}`), null);
assert.ok(!row.querySelector(`.${cls}`), cls);
row.querySelector('.data-toggle-btn').click();
}
assert.deepEqual(
calls.map(([id]) => id),
ids.slice(0, -1),
);
const ordinary = f.find((n) => n.dataset.layerId === 'other');
const ordinary = find('other');
assert.equal(
ordinary.querySelector('.data-toggle-controls-info').textContent,
'Detailed time',
@@ -195,19 +188,16 @@ test('readout rows contain only toggles and metadata; ordinary rows retain contr
assert.equal(typeof refresh, 'function');
} finally {
panel.destroy();
globalThis.document = previousDocument;
document.body.replaceChildren();
}
});
test('the Recent Imagery readout mounts in its rail body like the weather readout and is rebuilt or released with the panel', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const { railFixture } = await import('./railTestFixture.mjs');
const f = railFixture();
const body = f.document.createElement('div');
f.document.getElementById = (id) =>
id === 'recent-imagery-panel-body' ? body : null;
const previousDocument = globalThis.document;
globalThis.document = f.document;
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
const body = document.body.appendChild(document.createElement('div'));
body.id = 'recent-imagery-panel-body';
const mounted = [];
const factory = (container) => {
const readout = {
@@ -232,10 +222,10 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
try {
panel.attachRecentImagery(factory);
assert.equal(mounted.length, 0, 'no body before the panel mounts');
panel.mount(f.container);
panel.mount(container);
assert.equal(mounted.length, 1);
assert.equal(mounted[0].container, body);
panel.mount(f.container);
assert.ok(mounted[0].container === body);
panel.mount(container);
assert.equal(mounted[0].destroyed, true, 'a remount rebuilds it');
assert.equal(mounted.length, 2);
panel.attachRecentImagery(null);
@@ -248,18 +238,12 @@ test('the Recent Imagery readout mounts in its rail body like the weather readou
assert.equal(mounted.length, 3, 'a destroyed panel mounts nothing');
} finally {
panel.destroy();
globalThis.document = previousDocument;
document.body.replaceChildren();
}
});
test('an omitted source is visibly unavailable and cannot be toggled on', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
const { presentLayerToggle } = await import('./layerPanel.js');
const layer = {
id: 'flights',
name: 'Flights',
@@ -270,94 +254,63 @@ test('an omitted source is visibly unavailable and cannot be toggled on', async
error: 'Data source not configured: flights',
},
};
LayerPanel.prototype._syncToggleButton(button, layer);
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(attrs.get('aria-disabled'), 'true');
assert.match(button.title, /not configured/);
LayerPanel.prototype._syncToggleButton(button, { ...layer, enabled: true });
const toggle = presentLayerToggle(layer);
assert.equal(toggle.text, 'UNAVAILABLE');
assert.equal(toggle.ariaDisabled, true);
assert.match(toggle.title, /not configured/);
assert.equal(
attrs.get('aria-disabled'),
'false',
presentLayerToggle({ ...layer, enabled: true }).ariaDisabled,
false,
'an active layer can always be switched off',
);
});
test('row text keeps a hyphenated code whole and never starts a line with the separator', async () => {
const { setRowText } = await import('./layerPanel.js');
const previousDocument = globalThis.document;
const element = () => ({
className: '',
textContent: '',
children: [],
appendChild(child) {
this.children.push(child);
return child;
},
});
globalThis.document = { createElement: element };
try {
const text = (node) =>
node.children.length
? node.children.map((child) => child.textContent).join('')
: node.textContent;
const kept = (node) =>
node.children
.filter((child) => child.className === 'data-keep')
.map((child) => child.textContent);
const { rowTextRuns } = await import('./layerRowText.js');
const text = (runs) => runs.map((run) => run.text).join('');
const kept = (runs) => runs.filter((run) => run.keep).map((run) => run.text);
const name = element();
setRowText(name, 'Локальный ADS-B');
assert.equal(text(name), 'Локальный ADS-B');
assert.deepEqual(kept(name), ['ADS-B']);
const name = rowTextRuns('Локальный ADS-B');
assert.equal(text(name), 'Локальный ADS-B');
assert.deepEqual(kept(name), ['ADS-B']);
const meta = element();
setRowText(meta, 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
assert.ok(
meta.children.every((child) => !child.textContent.startsWith('·')),
'the space before each dot is held to the piece it closes',
);
const meta = rowTextRuns('UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.equal(text(meta), 'UNAVAILABLE · NASA FIRMS · LIVE · KEY REQUIRED');
assert.deepEqual(kept(meta), [' ·', ' ·', ' ·']);
assert.ok(
meta.every((run) => !run.text.startsWith('·')),
'the space before each dot is held to the piece it closes',
);
const plain = element();
setRowText(plain, 'Satellites');
assert.equal(plain.textContent, 'Satellites');
assert.equal(plain.children.length, 0);
assert.equal(rowTextRuns('Satellites'), null, 'plain text needs no runs');
const compound = element();
setRowText(compound, 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.deepEqual(
kept(compound),
[' ·', 'ADS-B'],
'a compound may break at its hyphen; the code inside it stays whole',
);
const compound = rowTextRuns('Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.equal(text(compound), 'Kartierte ALPR-Kameras · ADS-B-Flüge');
assert.deepEqual(
kept(compound),
[' ·', 'ADS-B'],
'a compound may break at its hyphen; the code inside it stays whole',
);
const long = element();
setRowText(long, 'see https-proxy-endpoint-config-x');
assert.deepEqual(kept(long), [], 'a long hyphenated run may still wrap');
} finally {
globalThis.document = previousDocument;
}
const long = rowTextRuns('see https-proxy-endpoint-config-x');
assert.deepEqual(
kept(long || []),
[],
'a long hyphenated run may still wrap',
);
});
test('the unavailable chip may abbreviate, but its accessible name keeps the full state', async () => {
const { LayerPanel } = await import('./layerPanel.js');
const attrs = new Map();
const button = {
classList: { toggle() {} },
dataset: {},
setAttribute: (key, value) => attrs.set(key, value),
};
LayerPanel.prototype._syncToggleButton(button, {
const { presentLayerToggle } = await import('./layerPanel.js');
const toggle = presentLayerToggle({
id: 'local-firms',
name: 'FIRMS Active Fires',
enabled: false,
stats: { sourceUnavailable: true, error: 'KEY REQUIRED' },
});
assert.equal(button.textContent, 'UNAVAILABLE');
assert.equal(button.dataset.feedState, 'unavailable');
assert.match(attrs.get('aria-label'), /^Active Fires: UNAVAILABLE\./);
assert.equal(toggle.text, 'UNAVAILABLE');
assert.equal(toggle.feedState, 'unavailable');
assert.match(toggle.ariaLabel, /^Active Fires: UNAVAILABLE\./);
});
test('the Weather readout receives enabled air-quality alongside weather layers', async () => {
+81
View File
@@ -0,0 +1,81 @@
<script setup>
import RailChips from '../rail/RailChips.vue';
import RailRowList from '../rail/RailRowList.vue';
import RowText from './RowText.vue';
import { vClassToggle } from '../rail/classToggle.js';
// The data layer rows under their group headings. LayerPanel presents every
// value; clicks reach it with the layer id, and it re-reads the live
// descriptor before acting.
defineProps({
items: { type: Array, required: true },
onToggle: { type: Function, default: () => {} },
onChip: { type: Function, default: () => {} },
onListItem: { type: Function, default: () => {} },
});
</script>
<template>
<template v-for="item in items" :key="item.key"
><h3 v-if="item.heading" class="data-layer-group-heading">{{
item.heading
}}</h3
><div
v-else
class="data-toggle-row"
v-class-toggle="{
'data-toggle-row--count-line': item.count?.countLine,
}"
:data-layer-id="item.id"
:data-layer-name="item.englishName"
><div class="data-toggle-top"
><div class="data-toggle-left"
><span class="data-icon" aria-hidden="true">{{ item.icon }}</span
><RowText class="data-name" :text="item.name" /></div
><div class="data-toggle-right"
><RowText
v-if="item.count"
:class="['data-count', { 'data-count-empty': item.count.empty }]"
:text="item.count.text"
/><button
type="button"
:class="['data-toggle-btn', item.toggle.classes]"
:data-feed-state="item.toggle.feedState"
:aria-disabled="String(item.toggle.ariaDisabled)"
:aria-busy="String(item.toggle.ariaBusy)"
:title="item.toggle.title"
:aria-label="item.toggle.ariaLabel"
@click="onToggle(item.id)"
>{{ item.toggle.text }}</button
></div
></div
><RowText tag="div" class="data-toggle-meta" :text="item.meta" /><template
v-if="item.controls"
><div class="data-toggle-controls" :hidden="item.controls.hidden"
><RailChips
:chips="item.controls.chips"
:on-select="(chip) => onChip(item.id, chip.id)"
/><span
v-for="(entry, index) in item.controls.legend"
:key="index"
class="data-toggle-legend-item"
:title="entry.blurb || undefined"
><span
class="data-toggle-legend-swatch"
:style="{ background: entry.color }"
></span
><span>{{ entry.text }}</span></span
><div
v-if="item.controls.info"
class="data-toggle-controls-info"
:title="item.controls.info.title"
:hidden="!item.controls.info.text"
>{{ item.controls.info.text }}</div
></div
><RailRowList
:list="item.controls.list"
:on-select="
(entry) => onListItem(item.id, entry.id)
" /></template></div
></template>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup>
import { computed } from 'vue';
import { rowTextRuns } from '../layerRowText.js';
// Row text that wraps well in every language: hyphenated codes and the
// space before a " · " separator never break (see rowTextRuns).
const props = defineProps({
tag: { type: String, default: 'span' },
text: { type: String, default: '' },
});
const runs = computed(() => rowTextRuns(props.text));
</script>
<template>
<component :is="tag"
><template v-if="runs"
><span
v-for="(run, index) in runs"
:key="index"
v-bind="run.keep ? { class: 'data-keep' } : {}"
>{{ run.text }}</span
></template
><template v-else>{{ text }}</template></component
>
</template>
+46
View File
@@ -0,0 +1,46 @@
// src/ui/layerPanel/layerRows.test.mjs — a layer row's own classes change
// without wiping classes other code adds, such as the voice highlight.
import '../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { h, render } from 'vue';
import LayerRows from './LayerRows.vue';
const row = (countLine) => ({
key: 'flights',
id: 'flights',
englishName: 'Flights',
icon: 'flight',
name: 'Flights',
meta: '',
toggle: {
classes: [],
text: 'OFF',
ariaLabel: 'Flights',
title: '',
ariaBusy: 'false',
ariaDisabled: 'false',
feedState: '',
},
count: { countLine, text: '12', empty: false },
controls: null,
});
test('the count-line class toggles without dropping the voice highlight', () => {
document.body.replaceChildren();
const container = document.body.appendChild(document.createElement('div'));
render(h(LayerRows, { items: [row(false)] }), container);
const element = container.querySelector('[data-layer-id="flights"]');
assert.equal(element.className, 'data-toggle-row');
element.classList.add('gev-voice-focus');
render(h(LayerRows, { items: [row(true)] }), container);
assert.equal(element.classList.contains('data-toggle-row--count-line'), true);
assert.equal(element.classList.contains('gev-voice-focus'), true);
render(h(LayerRows, { items: [row(false)] }), container);
assert.equal(element.classList.contains('data-toggle-row--count-line'), false);
assert.equal(element.classList.contains('gev-voice-focus'), true);
assert.equal(element.classList.contains('data-toggle-row'), true);
render(null, container);
});
+47
View File
@@ -0,0 +1,47 @@
const ROW_TEXT_SEPARATOR = ' · ';
// A short Latin code joined by hyphens: every part after a hyphen is capitals
// or digits (ADS-B, RTL-SDR, GTFS-RT). A compound such as "ALPR-Kameras" is a
// word, not a code, and may break at its hyphen. Longer runs (a URL, a token
// in an error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /\b[A-Za-z0-9]+(?:-[A-Z0-9]+)+(?![A-Za-z0-9])/g;
const HYPHENATED_CODE_MAX = 16;
/**
* Split a row's name, count or meta line so that a narrow column wraps it well
* in every language: a Latin code with hyphens (ADS-B, RTL-SDR, GTFS-RT) never
* splits at its hyphen, and a " · " separator stays at the end of the line it
* closes, so no line starts with "·". Each run is `{text, keep}`, where `keep`
* stops the browser breaking inside it; the runs join back to the text. Text
* with neither feature returns null and is shown as it is.
*
* @param {string} text
* @returns {Array<{text: string, keep: boolean}>|null}
*/
export function rowTextRuns(text) {
const value = String(text ?? '');
const codes = [...value.matchAll(HYPHENATED_CODE)].filter(
(match) => match[0].length <= HYPHENATED_CODE_MAX,
);
if (!codes.length && !value.includes(ROW_TEXT_SEPARATOR)) return null;
const runs = [];
const pushPlain = (from, to) => {
if (to <= from) return;
const pieces = value.slice(from, to).split(ROW_TEXT_SEPARATOR);
pieces.forEach((piece, index) => {
// The space before the dot belongs to a no-wrap run, so the only break
// opportunity around a separator is the space after it.
if (index > 0) runs.push({ text: ' ·', keep: true });
const textPiece = index > 0 ? ` ${piece}` : piece;
if (textPiece) runs.push({ text: textPiece, keep: false });
});
};
let cursor = 0;
for (const match of codes) {
pushPlain(cursor, match.index);
runs.push({ text: match[0], keep: true });
cursor = match.index + match[0].length;
}
pushPlain(cursor, value.length);
return runs;
}
+18
View File
@@ -87,3 +87,21 @@ for (const locale of ['es', 'ru']) {
}
});
}
test('a source a layer marks as data is shown untranslated in the row', () => {
setLocale('ru');
try {
const row = (stats) =>
LayerPanel.prototype._buildMetaText({
id: 'weather-lightning',
enabled: true,
stats: { count: 1, lastUpdate: Date.now(), ...stats },
});
assert.match(row({ source: 'Local ADS-B' }), /Локальный ADS-B/);
const verbatim = row({ source: 'Local ADS-B', sourceVerbatim: true });
assert.match(verbatim, /Local ADS-B/);
assert.doesNotMatch(verbatim, /Локальный/);
} finally {
setLocale('en');
}
});
+45 -16
View File
@@ -5,6 +5,7 @@ import {
resolvePanelStackCorridor,
} from '../panelStackLayout.js';
import { measurePanelNaturalHeight } from './panelMeasurement.js';
import { settleSideAccordion } from './panelAccordion.js';
/**
* Measure and place the left panel rail for one synchronous layout pass.
@@ -16,11 +17,13 @@ import { measurePanelNaturalHeight } from './panelMeasurement.js';
* @param {Window} options.windowRef Viewport and style reader.
* @param {{visible: boolean, variant: string}} options.hud Current HUD presentation.
* @param {string} options.preferredPanelId Most recently opened panel.
* @param {Function} options.onCollapse Update a panel's disclosure chrome.
* @param {Function} options.onCollapse Present a panel's collapse:
* `(panel, {collapsed, auto})`.
* @param {Function} options.onRetry Request another pass after automatic collapse.
* @param {Function} [options.getComputedStyle] Optional DOM style reader override.
* @param {Map<string, number>} options.collapsedHeights Caller-owned measurement cache.
* @param {Function} options.onAligned Notify the caller after the left rail moves.
* @param {Document} [options.documentRef] Document supplying current keyboard focus.
*/
export function layoutLeftPanelRail({
stack,
@@ -32,6 +35,7 @@ export function layoutLeftPanelRail({
onRetry,
collapsedHeights,
onAligned,
documentRef = stack?.ownerDocument,
getComputedStyle = (element) => windowRef.getComputedStyle(element),
}) {
if (!stack) return;
@@ -42,20 +46,23 @@ export function layoutLeftPanelRail({
for (const panel of panels.filter((item) =>
item.classList.contains('layout-auto-collapsed'),
)) {
panel.classList.remove('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: false, auto: null });
}
}
// The existing narrow-screen composition has its own full-width stack.
// Keep this desktop lane engine from fighting those dedicated rules.
if (windowRef.matchMedia('(max-width: 720px)').matches) {
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
if (isMobile) {
stack.classList.remove('layout-focus');
stack.classList.remove('layout-tail');
stack.style.removeProperty('--left-stack-safe-top');
stack.style.removeProperty('--left-stack-safe-bottom');
stack.style.removeProperty('--left-stack-centered-height');
stack.style.removeProperty('--panel-rail-offset');
stack.classList.remove('layout-exclusive');
stack.dataset.layoutMode = 'mobile';
settleSideAccordion({ panels, enabled: false, onCollapse });
for (const panel of panels) {
panel.removeAttribute('aria-hidden');
panel.style.removeProperty('--left-panel-allocated-height');
@@ -63,6 +70,23 @@ export function layoutLeftPanelRail({
return;
}
// One panel is open per desktop side, and the panel rail lists the others,
// so collapsed panels are hidden. Cockpit keeps its collapsed Data Layers
// strip, which its layout aligns to, and shows no rail.
settleSideAccordion({
panels,
enabled: true,
preferredPanelId,
documentRef,
onCollapse,
});
const exclusive =
!isMobile && !documentRef?.body?.classList?.contains('cockpit-mode');
stack.classList.toggle('layout-exclusive', exclusive);
const laidOutPanels = panels.filter(
(panel) => !exclusive || !panel.classList.contains('collapsed'),
);
const viewportHeight = Math.max(1, windowRef.innerHeight);
const stackRect = stack.getBoundingClientRect();
const baseTop = viewportHeight * 0.26;
@@ -162,7 +186,7 @@ export function layoutLeftPanelRail({
(sum, height) => sum + height,
0,
);
const siblingHeight = panels.reduce((total, panel) => {
const siblingHeight = laidOutPanels.reduce((total, panel) => {
if (!panel.classList.contains('collapsed')) return total;
const measured = collapsedHeights.get(panel.id);
return total + (measured || panel.getBoundingClientRect().height || 0);
@@ -170,12 +194,8 @@ export function layoutLeftPanelRail({
let requiredHeight = siblingHeight;
const rowGap = parseFloat(getComputedStyle(stack).rowGap) || 0;
if (expandedPanels.length) {
requiredHeight += naturalExpandedHeight;
requiredHeight += rowGap * Math.max(0, panels.length - 1);
} else {
requiredHeight += rowGap * Math.max(0, panels.length - 1);
}
if (expandedPanels.length) requiredHeight += naturalExpandedHeight;
requiredHeight += rowGap * Math.max(0, laidOutPanels.length - 1);
const wasFocused = stack.classList.contains('layout-focus');
const wasTail = stack.classList.contains('layout-tail');
@@ -189,7 +209,7 @@ export function layoutLeftPanelRail({
const tailRequiredHeight =
naturalExpandedHeight +
siblingHeight +
rowGap * Math.max(0, panels.length - 1);
rowGap * Math.max(0, laidOutPanels.length - 1);
// A compact expansion should not make the whole control stack jump down
// merely to center a few short rows. Preserve the normal top anchor when
// the centered stack would begin below it; tall stacks can still grow
@@ -243,8 +263,7 @@ export function layoutLeftPanelRail({
if (autoCollapseIndices.length) {
for (const index of autoCollapseIndices) {
const panel = expandedPanels[index];
panel.classList.add('collapsed', 'layout-auto-collapsed');
onCollapse(panel);
onCollapse(panel, { collapsed: true, auto: 'layout' });
}
onRetry();
return;
@@ -257,6 +276,16 @@ export function layoutLeftPanelRail({
) {
stack.style.setProperty('--left-stack-safe-bottom', bottomValue);
}
// The rail stays where the resting stack puts it; only the panels move.
// A theme that pins the stack's top (Cyber) leaves nothing to offset.
const topPinned =
getComputedStyle(stack)
.getPropertyValue?.('--panel-stack-top-pinned')
?.trim() === '1';
const railOffset = `${(topPinned ? 0 : safeTop - layoutTop).toFixed(1)}px`;
if (stack.style.getPropertyValue('--panel-rail-offset') !== railOffset) {
stack.style.setProperty('--panel-rail-offset', railOffset);
}
stack.style.removeProperty('--left-stack-centered-height');
for (const panel of panels)
panel.style.removeProperty('--left-panel-allocated-height');
@@ -296,8 +325,8 @@ export function layoutLeftPanelRail({
// CockpitView.syncSignalLayout() owns `--cockpit-utility-top` instead.
for (const panel of panels) {
const hiddenSibling = shouldFocus && panel.classList.contains('collapsed');
if (hiddenSibling) panel.setAttribute('aria-hidden', 'true');
if (exclusive && panel.classList.contains('collapsed'))
panel.setAttribute('aria-hidden', 'true');
else panel.removeAttribute('aria-hidden');
}
// The right controls share this top baseline; update them after the left
Loaded 100 of 630 files, more files were not shown because too many files have changed in this diff. Show more