merge main 07094d9 into feat/voice-outlines

This commit is contained in:
manjunath committed 2026-11-02 11:05:08 +05:30
commit c5aa0d28c3
593 files changed
+42386 -19629

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();
}
}
+319 -281
View File
@@ -1,3 +1,4 @@
import { nextTick } from 'vue';
import { resolveImageryHost } from '../layers/weather/imageryHost.js';
import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
@@ -9,7 +10,6 @@ import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
import { DisplayBindings } from './displayBindings.js';
import { createStateChannel } from '../app/stateChannel.js';
import { setSplitFlapText } from '../splitFlap.js';
import { UiLifetime } from './uiLifetime.js';
import { RecordingControls } from './recordingControls.js';
import { readShellElements } from './shellElements.js';
@@ -20,7 +20,6 @@ import { StreetLevelControls } from './streetLevelControls.js';
import { RadioControls } from './radio.js';
import { LocalSdrControls } from './localSdrControls.js';
import { LocationNavigation } from './locationNavigation.js';
import { bindClearLayersControl } from './layers.js';
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
import { createMapSourceControls } from './mapSource.js';
import { STYLES } from './effects.js';
@@ -43,14 +42,27 @@ import {
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
import {
describeUnsavedWork,
getLocale,
msg,
persistLocaleAndReload,
registerUnsavedWork,
onLocaleChange,
persistLocale,
setLocale,
t,
} from '../i18n/index.js';
import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
import { applyLocaleChoice } from './localePicker.js';
import { PanelNavigation } from './panelNavigation.js';
import { applicationPinia, createUiRuntime } from './vueRuntime.js';
import { useContextStore } from './stores/context.js';
import { useMissionReplayStore } from './stores/missionReplay.js';
import { useCelestialRingStore } from './stores/celestialRing.js';
import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js';
import { useAwarenessDirectionStore } from './stores/awarenessDirection.js';
import { useHudStore } from './stores/hud.js';
import { useDisplayStore } from './stores/display.js';
import { useLocaleStore } from './stores/locale.js';
import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useGlobeActionsStore } from './stores/globeActions.js';
import { useRecordingStore } from './stores/recording.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -75,6 +87,8 @@ export class StyleManager extends ShellFacade {
/**
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
* @param {object} [options]
* @param {boolean} [options.localePicker=true] - false removes the language
* picker, for a host that does not initialize Core's locale.
*/
constructor(
viewer,
@@ -84,15 +98,19 @@ export class StyleManager extends ShellFacade {
services,
requestServices,
initialShareCode = null,
localePicker = true,
} = {},
) {
super();
this._localePickerEnabled = localePicker;
const {
IntelHUD,
ShareLinkManager,
CelestialRing,
initTrackedReadout,
initWorldOverlay,
subscribeWorldOverlayActions,
activateWorldOverlayAction,
initDetection,
setDetectionStyle,
trafficLayer,
@@ -110,16 +128,19 @@ export class StyleManager extends ShellFacade {
this._lifetime = new UiLifetime();
this._recording = new RecordingControls({
syncShareState: () => this._syncShareState(),
syncHudControls: () => this._visualSettings._syncHudControls(),
publishSafeFrame: (frame) =>
useRecordingStore(applicationPinia()).publishSafeFrame(frame),
});
Object.assign(this, readShellElements());
this._panelChrome = new PanelChrome({
elements: {
_leftPanelStack: this._leftPanelStack,
_locationSearch: this._locationSearch,
_ppToggles: this._ppToggles,
_rightPanelStack: this._rightPanelStack,
},
operations: {
_closeLocationSearch: () => this._locationNavigation?.closeSearch(),
_syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args),
_showToast: (...args) => this._showToast(...args),
@@ -133,6 +154,8 @@ export class StyleManager extends ShellFacade {
readSharedCollapsed: (panelId) =>
this._mobileShell?.sharedCollapsed(panelId),
});
// Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime({ pinia: applicationPinia() });
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
});
@@ -150,7 +173,8 @@ export class StyleManager extends ShellFacade {
militaryAwarenessLayer,
rocketLaunchesLayer: services.rocketLaunchesLayer,
},
searchInput: this._locationSearch,
settleSearch: (options) =>
this._locationNavigation?.settleSearch(options),
interruptCameraMotion: services.interruptCameraMotion,
interruptGlobeRotation: services.interruptGlobeRotation,
isCockpitActive: () => !!this.cockpitView?.active,
@@ -199,49 +223,8 @@ export class StyleManager extends ShellFacade {
setScopeMaskFeather: services.setScopeMaskFeather,
},
elements: {
_bloomBtn: this._bloomBtn,
_bloomSlider: this._bloomSlider,
_bloomSliderRow: this._bloomSliderRow,
_bloomSliderValue: this._bloomSliderValue,
_celestialBtn: this._celestialBtn,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_detectionAllocationRow: this._detectionAllocationRow,
_detectionBtn: this._detectionBtn,
_detectionDensitySlider: this._detectionDensitySlider,
_detectionDensityValue: this._detectionDensityValue,
_detectionFadeRow: this._detectionFadeRow,
_detectionFadeSlider: this._detectionFadeSlider,
_detectionFadeValue: this._detectionFadeValue,
_detectionOpacityRow: this._detectionOpacityRow,
_detectionOpacitySlider: this._detectionOpacitySlider,
_detectionOpacityValue: this._detectionOpacityValue,
_detectionSliderRow: this._detectionSliderRow,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRingsValue: this._cyberSonarRingsValue,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarRangeValue: this._cyberSonarRangeValue,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarIntensityValue: this._cyberSonarIntensityValue,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarOpacityValue: this._cyberSonarOpacityValue,
_cyberSonarSector: this._cyberSonarSector,
_cyberSonarSectorValue: this._cyberSonarSectorValue,
_hudLayoutRow: this._hudLayoutRow,
_hudLayoutSelect: this._hudLayoutSelect,
_ppToggles: this._ppToggles,
_scopeBtn: this._scopeBtn,
_scopeFeatherSlider: this._scopeFeatherSlider,
_scopeFeatherValue: this._scopeFeatherValue,
_sharpenBtn: this._sharpenBtn,
_sharpenSlider: this._sharpenSlider,
_sharpenSliderRow: this._sharpenSliderRow,
_sharpenSliderValue: this._sharpenSliderValue,
_sliderContainer: this._sliderContainer,
_sliderPanel: this._sliderPanel,
_styleIndicator: this._styleIndicator,
_styleMiniValue: this._styleMiniValue,
},
operations: {
_restorePanelState: (...args) => this._restorePanelState(...args),
@@ -251,9 +234,9 @@ export class StyleManager extends ShellFacade {
_scheduleRightPanelLayout: (...args) =>
this._scheduleRightPanelLayout(...args),
_setCockpitDisclosure: (...args) => this._setCockpitDisclosure(...args),
_cockpitDisclosureOpen: (kind) => this._cockpitDisclosureOpen(kind),
_setMapStack: (...args) => this._setMapStack(...args),
_syncPanelCollapseButton: (...args) =>
this._syncPanelCollapseButton(...args),
_presentPanel: (...args) => this._presentPanel(...args),
_syncShareState: (...args) => this._syncShareState(...args),
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
},
@@ -339,18 +322,15 @@ export class StyleManager extends ShellFacade {
rocketLaunchesLayer: services.rocketLaunchesLayer,
},
elements: {
_locationPills: this._locationPills,
_poiRow: this._poiRow,
_locationBarDivider: this._locationBarDivider,
_locationSearch: this._locationSearch,
_locationSearchError: this._locationSearchError,
_searchToggle: this._searchToggle,
_resetGlobeBtn: this._resetGlobeBtn,
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
_locationMiniCity: this._locationMiniCity,
_locationMiniPoi: this._locationMiniPoi,
},
operations: {
_publishLocation: (view) =>
useLocationStore(applicationPinia()).publish(view),
_connectLocation: (controls) =>
useLocationStore(applicationPinia()).connect(controls),
_connectGlobeReset: (controls) => this._connectGlobeReset(controls),
_publishGlobeResetting: (busy) => this._publishGlobeResetting(busy),
_beginDeferredNavigation: (...args) =>
this._beginDeferredNavigation(...args),
_reassertNavigationHandoff: (...args) =>
@@ -368,9 +348,11 @@ export class StyleManager extends ShellFacade {
this._lastTrafficChipUpdateAt = 0;
// Intel HUD
const hudStore = useHudStore(applicationPinia());
this.hud = new IntelHUD(viewer, {
placeSearch,
summaryService: requestServices?.summary,
publish: (view) => hudStore.publish(view),
});
this._recording.hud = this.hud;
this._cockpitCoordinator = new CockpitCoordinator({
@@ -402,6 +384,26 @@ export class StyleManager extends ShellFacade {
_models3dBtn: this._models3dBtn,
},
operations: {
publishCockpit: {
presence: (view) =>
useCockpitStore(applicationPinia()).publishPresence(view),
brief: (view) =>
useCockpitStore(applicationPinia()).publishBrief(view),
context: (view) =>
useCockpitStore(applicationPinia()).publishContext(view),
instruments: (view) =>
useCockpitStore(applicationPinia()).publishInstruments(view),
route: (view) =>
useCockpitStore(applicationPinia()).publishRoute(view),
vision: (view) =>
useCockpitStore(applicationPinia()).publishVision(view),
signals: (rows) =>
useCockpitStore(applicationPinia()).publishSignals(rows),
utilityLayout: (layout) =>
useCockpitStore(applicationPinia()).publishUtilityLayout(layout),
},
publishCockpitPortal: (slots) =>
useDisplayStore(applicationPinia()).publishCockpitPortal(slots),
_layoutRightPanels: (...args) => this._layoutRightPanels(...args),
_setCockpitVision: (...args) => this._setCockpitVision(...args),
_stampNavigation: (...args) => this._stampNavigation(...args),
@@ -413,6 +415,9 @@ export class StyleManager extends ShellFacade {
readDataManager: () => this._dataManager,
readContext: () => this.getContextModeState(),
readActiveStyle: () => this.activeStyle,
readUtilityDisclosureOpen: () =>
this._cockpitDisclosureOpen('display') ||
this._cockpitDisclosureOpen('radio'),
enterPanels: () => this._panelChrome.enterCockpit(),
exitPanels: () => this._panelChrome.exitCockpit(),
});
@@ -421,6 +426,7 @@ export class StyleManager extends ShellFacade {
// Cesium canvas but below the HUD/detection/readout z ladder.
this.celestialRing = new CelestialRing(viewer, {
enabled: false,
store: useCelestialRingStore(applicationPinia()),
onAutoDisable: () =>
this.setCelestialRingEnabled(false, {
syncShare: !!this.shareLinkManager,
@@ -475,7 +481,6 @@ export class StyleManager extends ShellFacade {
this._aircraftDisplay = new AircraftDisplay({
elements: {
_models3dBtn: this._models3dBtn,
_models3dModeRow: this._models3dModeRow,
},
readDataManager: () => this._dataManager,
layout: () => this._layoutRightPanels(),
@@ -485,6 +490,13 @@ export class StyleManager extends ShellFacade {
// detection and tracked-readout initialize. It stays transparent until a
// production source explicitly registers entries.
initWorldOverlay(viewer);
// Its focusable mirror of the painted cards, which the scene chrome
// renders.
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect({ activate: activateWorldOverlayAction });
this._overlayActionsUnsubscribe = subscribeWorldOverlayActions((view) =>
overlayActionsStore.publish(view),
);
// Initialize detection overlay BEFORE style stages so the composite
// stage is first in the post-process pipeline
@@ -507,7 +519,7 @@ export class StyleManager extends ShellFacade {
);
initTrackedReadout(viewer);
setDetectionStyle(this.activeStyle);
this._applyDetectionDensityFromUi();
this._setDetectionDensity();
this._initStages();
this._initBloomSharpen();
@@ -515,39 +527,14 @@ export class StyleManager extends ShellFacade {
viewer,
services: {
cycleDetectionMode: services.cycleDetectionMode,
setScopeMaskEnabled: services.setScopeMaskEnabled,
isScopeMaskEnabled: services.isScopeMaskEnabled,
setScopeMaskFeather: services.setScopeMaskFeather,
},
elements: {
_locationSearch: this._locationSearch,
_bloomBtn: this._bloomBtn,
_bloomSlider: this._bloomSlider,
_sharpenBtn: this._sharpenBtn,
_sharpenSlider: this._sharpenSlider,
_scopeBtn: this._scopeBtn,
_scopeFeatherSlider: this._scopeFeatherSlider,
_hudLayoutSelect: this._hudLayoutSelect,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarSector: this._cyberSonarSector,
_cleanViewBtn: this._cleanViewBtn,
_cleanViewExitBtn: this._cleanViewExitBtn,
_detectionDensitySlider: this._detectionDensitySlider,
_detectionBtn: this._detectionBtn,
_detectionFadeSlider: this._detectionFadeSlider,
_detectionOpacitySlider: this._detectionOpacitySlider,
_celestialBtn: this._celestialBtn,
_models3dBtn: this._models3dBtn,
_scopeFeatherValue: this._scopeFeatherValue,
_sharpenSliderValue: this._sharpenSliderValue,
_detectionDensityValue: this._detectionDensityValue,
},
operations: {
_closeLocationSearch: (options) =>
this._locationNavigation?.closeSearch(options),
setStyle: (...args) => this.setStyle(...args),
_updateHudButtonState: (...args) => this._updateHudButtonState(...args),
_syncShareState: (...args) => this._syncShareState(...args),
@@ -557,17 +544,15 @@ export class StyleManager extends ShellFacade {
_setBloomEnabled: (...args) => this._setBloomEnabled(...args),
_setBloomIntensity: (...args) => this._setBloomIntensity(...args),
_setSharpenEnabled: (...args) => this._setSharpenEnabled(...args),
_applySharpenIntensity: (...args) =>
this._applySharpenIntensity(...args),
_setSharpenIntensity: (...args) => this._setSharpenIntensity(...args),
_setHudVariant: (...args) => this._setHudVariant(...args),
_setCyberSonarEnabled: (...args) => this._setCyberSonarEnabled(...args),
_setCyberSonarSetting: (...args) => this._setCyberSonarSetting(...args),
_applyDetectionDensityFromUi: (...args) =>
this._applyDetectionDensityFromUi(...args),
_setDetectionDensity: (...args) => this._setDetectionDensity(...args),
_setDetectionAllocation: (...args) =>
this._setDetectionAllocation(...args),
_applyDetectionFadeFromUi: (...args) =>
this._applyDetectionFadeFromUi(...args),
_setDetectionFade: (...args) => this._setDetectionFade(...args),
_setScope: (...args) => this._visualSettings._setScope(...args),
setCelestialRingEnabled: (...args) =>
this.setCelestialRingEnabled(...args),
_setModels3dEnabled: (...args) => this._setModels3dEnabled(...args),
@@ -582,8 +567,6 @@ export class StyleManager extends ShellFacade {
celestialRing: this.celestialRing,
celestialRingEnabled: this.celestialRingEnabled,
_models3dEnabled: this._models3dEnabled,
_models3dModeBtns: this._models3dModeBtns,
_detectionAllocationBtns: this._detectionAllocationBtns,
}),
claimDetection: () => {
this._visualSettings._detectionUserOverridden = true;
@@ -591,9 +574,14 @@ export class StyleManager extends ShellFacade {
});
this._initUI();
this._initMapStackControl();
// Headers first: panel chrome builds its toggle labels from them.
this._applyRuntimeStaticHeaderText();
this._initPanelChrome();
this._panelNavigation = new PanelNavigation({
ui: this._ui,
stacks: { left: this._leftPanelStack, right: this._rightPanelStack },
setPanelCollapsed: (...args) =>
this._panelChrome.setPanelCollapsed(...args),
});
this._panelNavigation.init();
this._initLocaleSelector();
this._sizeDisplayLabelColumn();
this._initLeftPanelAdaptiveLayout();
@@ -614,7 +602,7 @@ export class StyleManager extends ShellFacade {
this._initRecordingOverlay();
this._startAnimationLoop();
this._startTrafficChipTicker();
this._updateStyleMiniStatus();
this._publishActiveStyle();
this._updateLocationMiniStatus();
this._shareRestoration.start();
@@ -731,64 +719,51 @@ export class StyleManager extends ShellFacade {
}
/**
* Mounts the language picker (src/ui/localePicker.js). A choice reloads the
* page; _confirmLocaleReload asks first when that would lose work.
* Publishes the language picker, which ProviderSettingsView renders. A
* choice switches the page's language in place and is kept for the next
* visit; the Display panel's label column is sized again for it.
* @returns {void}
*/
_initLocaleSelector() {
// Work this shell can see that a reload drops. Other modules register
// their own (registerUnsavedWork in src/i18n/index.js).
this._unsavedWorkChecks = [
registerUnsavedWork(() =>
this.services?.cctvLayer
?.getUIState?.()
?.cameras?.some((camera) => camera.calDirty)
? msg('unsaved CCTV calibration')
: null,
),
registerUnsavedWork(() =>
this.cockpitView?.active ? msg('the open cockpit') : null,
),
];
const root = globalThis.document?.getElementById?.('locale-picker');
if (!root) return;
this._localePicker = mountLocalePicker({
root,
this._lifetime.own(
onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())),
);
// A host that does not initialize Core's locale shows no picker.
if (this._localePickerEnabled === false) return;
this._publishLocalePicker();
}
/**
* @param {{focus?: boolean}} [options] Focus the picker's button, as after
* a choice: the picker is drawn again for the new language.
*/
_publishLocalePicker({ focus = false } = {}) {
useLocaleStore(applicationPinia()).publishPicker({
current: getLocale(),
onSelect: (locale) => this._chooseLocale(locale),
focus,
});
}
/**
* Switch to `locale`: nothing for the active language, otherwise store it
* and reload once _confirmLocaleReload agrees.
* and apply it to the page.
* @param {string} locale
* @returns {'unchanged'|'declined'|'switched'}
* @returns {'unchanged'|'switched'}
*/
_chooseLocale(locale) {
return applyLocaleChoice(locale, {
current: getLocale(),
confirmSwitch: () => this._confirmLocaleReload(),
switchLocale: (next) => persistLocaleAndReload(next),
switchLocale: (next) => {
setLocale(persistLocale(next));
// Focus once, as the redrawn picker mounts; a later remount leaves
// focus where it is.
this._publishLocalePicker({ focus: true });
void nextTick(() => this._publishLocalePicker());
},
});
}
/**
* Switching language reloads the page. While that would lose work the page
* cannot restore, ask first.
* @returns {boolean} true to switch.
*/
_confirmLocaleReload() {
const lost = describeUnsavedWork();
if (!lost.length || typeof globalThis.confirm !== 'function') return true;
return globalThis.confirm(
t(
'Switching language reloads the page. This will be lost: {items}. Switch anyway?',
{ items: lost.join(', ') },
),
);
}
/**
* Sizes one Display-panel label column to the longest label in the active
* language, so slider tracks line up. Counts characters (labels are
@@ -811,60 +786,6 @@ export class StyleManager extends ShellFacade {
}
}
/**
* Rewrites a header's last text node, keeping the leading icon span that a
* textContent write would destroy.
* @param {Element|null} element
* @param {string} text
* @returns {void}
*/
_setHeaderText(element, text) {
if (!element) return;
const textNode = Array.from(element.childNodes)
.reverse()
.find(
(node) => node.nodeType === Node.TEXT_NODE && node.nodeValue?.trim(),
);
if (textNode) textNode.nodeValue = text;
else element.textContent = text;
}
/**
* Translates at boot the header texts that share their element with an
* icon or a <br> (data-i18n-runtime in the templates).
* @returns {void}
*/
_applyRuntimeStaticHeaderText() {
this._setHeaderText(
document.querySelector('#control-panel .panel-title'),
t('VISUAL PRESETS'),
);
this._setHeaderText(
document.querySelector('#location-bar .location-toolbar-label'),
t('LOCATION'),
);
// One span holds both mode descriptions around a <br>; keep the <br>.
const standbyDescription = document.querySelector(
'#context-mode-standby span',
);
if (standbyDescription) {
standbyDescription.replaceChildren(
document.createTextNode(
t('CONTACTS — nearest planes · vessels · sites'),
),
document.createElement('br'),
document.createTextNode(
t('SPACE MISSIONS — launches & orbital assets'),
),
);
}
// Leading space after the live dot, as showBriefPage writes it.
const briefKicker = document.querySelector('#cockpit-brief-kicker');
if (briefKicker) {
this._setHeaderText(briefKicker, ` ${t('LIVE SIGNALS')}`);
}
}
/**
* Phone layout: a bottom tab bar presenting one panel sheet at a time
* (src/ui/mobileShell.js). Inactive above 720px.
@@ -874,10 +795,16 @@ export class StyleManager extends ShellFacade {
this._mobileShell = initMobileShell({
setPanelCollapsed: (panelId, collapsed, options) =>
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
readPanel: (panel) => this._panelChrome._shownPanel(panel),
onSheetsClosed: () => this._syncShareState(),
onTabOpened: (sheet) => {
void this._onMobileTabOpened(sheet);
},
publish: (view) =>
useDisplayStore(applicationPinia()).publishPhoneShell(view),
afterPublish: (callback) => {
void nextTick(callback);
},
});
}
@@ -916,11 +843,12 @@ export class StyleManager extends ShellFacade {
_initMapStackControl() {
if (!this.mapStackController) return;
const displayStore = useDisplayStore(applicationPinia());
this._mapSourceControls?.destroy();
displayStore.publishMapSource(null);
this._mapSourceControls = createMapSourceControls({
container: this._mapStackChips,
statusElement: this._mapStackStatus,
controller: this.mapStackController,
publish: (view) => displayStore.publishMapSource(view),
subscribe: (onChange) => {
window.addEventListener('gev:map-stack-changed', onChange);
return () =>
@@ -930,6 +858,7 @@ export class StyleManager extends ShellFacade {
onStateChanged: () => this._syncShareState(),
onError: (message) => this._showToast(message),
});
displayStore.connectMapSource(this._mapSourceControls);
}
/**
@@ -997,18 +926,21 @@ export class StyleManager extends ShellFacade {
}
_initGlobalContextPanel() {
const { radioLayer, militaryInstallationsLayer } = this.services;
const {
radioLayer,
militaryInstallationsLayer,
militaryAwarenessLayer,
rocketLaunchesLayer,
} = this.services;
const contextStore = useContextStore(applicationPinia());
this._contextControls = new ContextControls({
elements: {
_globalContextPanel: document.getElementById('global-context-panel'),
_globalContextFlightsBtn: this._globalContextFlightsBtn,
_globalContextMissionsBtn: this._globalContextMissionsBtn,
_contextModeStandby: this._contextModeStandby,
_contextFlightsView: this._contextFlightsView,
_contextMissionsView: this._contextMissionsView,
_installationsSearchBtn: this._installationsSearchBtn,
},
installations: militaryInstallationsLayer,
publish: (view) => contextStore.publish(view),
actions: {
getCockpit: () => this.cockpitView,
refreshRadio: () => this._renderRadioState(radioLayer.getUIState()),
@@ -1020,11 +952,41 @@ export class StyleManager extends ShellFacade {
showToast: (message) => {
if (!this._disposed) this._showToast(message);
},
setClearBusy: (busy) => {
if (!this._disposed) this._clearLayersControl?.setBusy(busy);
},
setClearBusy: (busy) => this._publishClearBusy(busy),
},
});
// The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer);
// A panel left by an earlier shell must not outlive its layer.
this._awarenessPanelUnsubscribe?.();
contextStore.publishAwareness(null);
this._awarenessPanelUnsubscribe = militaryAwarenessLayer?.subscribePanel?.(
(view) => contextStore.publishAwareness(view),
);
// And its compass and contact bearings, which the scene chrome renders.
const awarenessDirectionStore =
useAwarenessDirectionStore(applicationPinia());
this._awarenessDirectionUnsubscribe?.();
awarenessDirectionStore.publish(null);
this._awarenessDirectionUnsubscribe =
militaryAwarenessLayer?.subscribeDirectionOverlay?.((view) =>
awarenessDirectionStore.publish(view),
);
// The Space Missions layer publishes its roster and selected mission.
contextStore.connectMissions(rocketLaunchesLayer);
this._missionsPanelUnsubscribe?.();
contextStore.publishMissions(null);
this._missionsPanelUnsubscribe = rocketLaunchesLayer?.subscribePanel?.(
(view) => contextStore.publishMissions(view),
);
// And its replay vehicle overlay, which the scene chrome renders.
const missionReplayStore = useMissionReplayStore(applicationPinia());
this._missionReplayUnsubscribe?.();
missionReplayStore.publish(null);
this._missionReplayUnsubscribe =
rocketLaunchesLayer?.subscribeReplayOverlay?.((view) =>
missionReplayStore.publish(view),
);
}
/** Wire the independent Radio companion controls. */
@@ -1033,37 +995,19 @@ export class StyleManager extends ShellFacade {
this._radioControls?.destroy();
this._radioControls = new RadioControls({
elements: {
_cockpitDisplayPanel: this._cockpitDisplayPanel,
_cockpitDisplayToggleBtn: this._cockpitDisplayToggleBtn,
_cockpitRadioEnableBtn: this._cockpitRadioEnableBtn,
_cockpitRadioNextBtn: this._cockpitRadioNextBtn,
_cockpitRadioPanel: this._cockpitRadioPanel,
_cockpitRadioPlayBtn: this._cockpitRadioPlayBtn,
_cockpitRadioPrevBtn: this._cockpitRadioPrevBtn,
_cockpitRadioStation: this._cockpitRadioStation,
_cockpitRadioToggleBtn: this._cockpitRadioToggleBtn,
_cockpitRadioVolume: this._cockpitRadioVolume,
_cockpitRadioVolumeValue: this._cockpitRadioVolumeValue,
_cockpitUtilityControls: this._cockpitUtilityControls,
_radioEnableBtn: this._radioEnableBtn,
_radioFilter: this._radioFilter,
_radioLayerState: this._radioLayerState,
_radioNextBtn: this._radioNextBtn,
_radioPanel: this._radioPanel,
_radioPlayBtn: this._radioPlayBtn,
_radioPlaybackState: this._radioPlaybackState,
_radioPrevBtn: this._radioPrevBtn,
_radioStationHomepage: this._radioStationHomepage,
_radioStationMeta: this._radioStationMeta,
_radioStationName: this._radioStationName,
_radioStationTags: this._radioStationTags,
_radioStopBtn: this._radioStopBtn,
_radioTuner: this._radioTuner,
_radioTunerBandLabel: this._radioTunerBandLabel,
_radioTunerNeedle: this._radioTunerNeedle,
_radioTunerSlider: this._radioTunerSlider,
_radioTunerStation: this._radioTunerStation,
_radioTunerValue: this._radioTunerValue,
_radioVolume: this._radioVolume,
_radioVolumeValue: this._radioVolumeValue,
},
@@ -1193,9 +1137,6 @@ export class StyleManager extends ShellFacade {
_cctvSelect: this._cctvSelect,
_cctvSourceBadge: this._cctvSourceBadge,
_cctvSummary: this._cctvSummary,
_cctvSyncChip: this._cctvSyncChip,
_cctvSyncLabel: this._cctvSyncLabel,
_cctvSyncProgress: this._cctvSyncProgress,
},
cctv: cctvLayer,
actions: {
@@ -1207,7 +1148,6 @@ export class StyleManager extends ShellFacade {
setPanelCollapsed: (...args) => this.setPanelCollapsed(...args),
showToast: (message) => this._showToast(message),
syncViewport: () => this._syncCctvPanelViewport(),
setSplitFlapText,
},
});
}
@@ -1460,20 +1400,18 @@ export class StyleManager extends ShellFacade {
hud: {
visible: !!this.hud?.visible,
layout: this.hud?.getVariant?.() || null,
sonar: getCyberSonarControlState(),
sonar: getCyberSonarControlState(globalThis.document, {
hudActive: this.hud ? !!this.hud.visible : undefined,
}),
},
detection: this.getDetectionState(),
bloom: {
enabled: !!this.bloomEnabled,
intensityPct: this._bloomSlider
? parseInt(this._bloomSlider.value, 10)
: null,
intensityPct: this._visualSettings.bloomIntensityPct,
},
sharpen: {
enabled: !!this.sharpenEnabled,
intensityPct: this._sharpenSlider
? parseInt(this._sharpenSlider.value, 10)
: null,
intensityPct: this._visualSettings.sharpenIntensityPct,
},
celestialRing: {
enabled: this.celestialRingEnabled,
@@ -1623,29 +1561,105 @@ export class StyleManager extends ShellFacade {
return this._releaseFollowCamera({ preserveVesselSelection: false });
}
/** Wire the top-center action that clears only manager-owned data layers. */
/**
* Answer the top-center action that clears only manager-owned data layers;
* the caller owns its transaction, and the button stays focusable while it
* runs.
*/
_initClearSelectedLayersButton() {
if (!this._clearSelectedLayersBtn) return;
this._clearLayersControl?.destroy();
this._clearLayersControl = bindClearLayersControl(
this._clearSelectedLayersBtn,
() => this.clearSelectedLayers(),
);
const store = useGlobeActionsStore(applicationPinia());
if (this._clearLayersToken) store.detach(this._clearLayersToken);
this._clearLayersToken = store.connect({
clear: () => {
if (!this._disposed) void this.clearSelectedLayers();
},
});
// A clear under way as the shell connects again stays on the button.
if (this._clearBusy != null)
store.publishClearBusy(this._clearLayersToken, this._clearBusy);
}
/**
* Publish this shell's clear under way; the button shows it while this
* shell answers the clear.
*/
_publishClearBusy(busy) {
if (this._disposed) return;
this._clearBusy = Boolean(busy);
if (this._clearLayersToken)
useGlobeActionsStore(applicationPinia()).publishClearBusy(
this._clearLayersToken,
busy,
);
}
/**
* Answer the globe and cockpit reset buttons through the location controls,
* or stop answering them (null).
*/
_connectGlobeReset(controls) {
const store = useGlobeActionsStore(applicationPinia());
if (this._resetToken) store.detach(this._resetToken);
this._resetToken = controls
? store.connect({ reset: () => controls.reset() })
: null;
// A reset under way as the controls are replaced stays on the buttons.
if (this._resetToken && this._globeResetting != null)
store.publishResetting(this._resetToken, this._globeResetting);
}
/** Publish a reset to the full globe view under way. */
_publishGlobeResetting(busy) {
if (this._disposed) return;
this._globeResetting = Boolean(busy);
if (this._resetToken)
useGlobeActionsStore(applicationPinia()).publishResetting(
this._resetToken,
busy,
);
}
/**
* Stop answering the globe actions. Its clear under way goes with its
* owner, and its camera state too; a newer shell's stays.
*/
_releaseGlobeActions() {
const store = useGlobeActionsStore(applicationPinia());
if (store.owns(this._orientationToken, 'tilt')) store.publishCamera(null);
if (this._clearLayersToken) store.detach(this._clearLayersToken);
if (this._shareToken) store.detach(this._shareToken);
if (this._orientationToken) store.detach(this._orientationToken);
if (this._resetToken) store.detach(this._resetToken);
this._clearLayersToken = this._shareToken = this._orientationToken = null;
this._resetToken = null;
}
/** Wire Google Maps-style tilt and north-up camera actions. */
_initCameraOrientationControls() {
const store = useGlobeActionsStore(applicationPinia());
this._cameraOrientationControls?.destroy();
this._cameraOrientationControls = bindCameraOrientationControls({
if (this._orientationToken) store.detach(this._orientationToken);
const token = store.connect({
tilt: () => controls.tilt(),
northUp: () => controls.northUp(),
});
this._orientationToken = token;
// A newer shell's controls own what the buttons show.
const controls = bindCameraOrientationControls({
viewer: this.viewer,
elements: {
tiltButton: this._tiltMapBtn,
northButton: this._northUpBtn,
// Every drawn frame publishes: a state the store already shows stops
// here, before the store's actions run.
publish: (state) => {
const shown = store.camera;
if (shown?.tilted === state.tilted && shown.heading === state.heading)
return;
if (store.owns(token, 'tilt')) store.publishCamera(state);
},
runNavigation: (noun, navigate) =>
this._navigation.runOrientation(noun, navigate),
showToast: (message) => this._showToast(message),
});
this._cameraOrientationControls = controls;
}
// ── Share Button ─────────────────────────────
@@ -1658,29 +1672,35 @@ export class StyleManager extends ShellFacade {
* @returns {void}
*/
_initShareButton() {
this._lifetime.listen(this._shareBtn, 'click', async () => {
const share = this.services.shareLink;
let result;
if (typeof share === 'function') {
// A host's hook may return a plain value or throw; both are answers.
result = await Promise.resolve()
.then(() => {
const href = this.shareLinkManager.buildLink();
return href ? share(href) : false;
})
.catch(() => false);
} else {
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
}
if (this._disposed || result === 'cancelled') return;
this._showToast(
result === 'shared'
? t('Link shared')
: result === 'copied'
? t('Link copied!')
: t('Copy failed'),
);
});
const store = useGlobeActionsStore(applicationPinia());
if (this._shareToken) store.detach(this._shareToken);
this._shareToken = store.connect({ share: () => this._share() });
}
/** Share or copy the current link, then say how it went. */
async _share() {
if (this._disposed) return;
const share = this.services.shareLink;
let result;
if (typeof share === 'function') {
// A host's hook may return a plain value or throw; both are answers.
result = await Promise.resolve()
.then(() => {
const href = this.shareLinkManager.buildLink();
return href ? share(href) : false;
})
.catch(() => false);
} else {
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
}
if (this._disposed || result === 'cancelled') return;
this._showToast(
result === 'shared'
? t('Link shared')
: result === 'copied'
? t('Link copied!')
: t('Copy failed'),
);
}
// ── HUD Toggle ───────────────────────────────
@@ -1702,17 +1722,15 @@ export class StyleManager extends ShellFacade {
*/
_initHUDToggle() {
if (this._hudLayoutSelect) {
this._hudLayoutSelect.value = 'tactical';
}
this._setHudVariant('tactical');
this.hud.setMode('on');
this._updateHudButtonState();
this._lifetime.listen(this._cockpitDisplayToggleBtn, 'click', () => {
const open =
this._cockpitDisplayToggleBtn.getAttribute('aria-expanded') === 'true';
this._setCockpitDisclosure?.('display', !open);
this._setCockpitDisclosure?.(
'display',
!this._cockpitDisclosureOpen('display'),
);
});
this._initCockpitDisplayPortal();
}
@@ -1763,31 +1781,46 @@ export class StyleManager extends ShellFacade {
this.services;
if (this._disposed) return;
this._shareRestoration.destroy();
this._feedback._globalStatusNotice = null;
if (this._globalLoadingStatus) this._globalLoadingStatus.hidden = true;
this._feedback.clearLoadingStatus();
this._disposed = true;
this._navigation.stop();
this._shareState.destroy();
this._locationNavigation.destroy();
this._lifetime.destroy();
for (const unregister of this._unsavedWorkChecks?.splice(0) ?? [])
unregister();
this._localePicker?.destroy();
this._localePicker = null;
useLocaleStore(applicationPinia()).publishPicker(null);
this._recording.destroy();
this._mobileShell?.destroy();
this._panelNavigation?.destroy();
this._ui.destroy();
this._panelChrome.destroy();
this._feedback.destroy();
this._displayBindings.destroy();
this._mapSourceControls?.destroy();
useDisplayStore(applicationPinia()).connectMapSource(null);
useDisplayStore(applicationPinia()).publishMapSource(null);
this._cameraOrientationControls?.destroy();
this._clearLayersControl?.destroy();
this._releaseGlobeActions();
this._cctvControls?.destroy();
this.services?.streetLevelLayer?.attachNavigation?.(null);
this._streetLevelControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();
this._awarenessPanelUnsubscribe?.();
this._awarenessPanelUnsubscribe = null;
this._awarenessDirectionUnsubscribe?.();
this._awarenessDirectionUnsubscribe = null;
useAwarenessDirectionStore(applicationPinia()).publish(null);
this._missionsPanelUnsubscribe?.();
this._missionsPanelUnsubscribe = null;
this._missionReplayUnsubscribe?.();
this._missionReplayUnsubscribe = null;
useMissionReplayStore(applicationPinia()).publish(null);
const contextStore = useContextStore(applicationPinia());
contextStore.connectAwareness(null);
contextStore.publishAwareness(null);
contextStore.connectMissions(null);
contextStore.publishMissions(null);
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
@@ -1817,7 +1850,12 @@ export class StyleManager extends ShellFacade {
}
destroyTrackedReadout();
destroyDetection();
this._overlayActionsUnsubscribe?.();
this._overlayActionsUnsubscribe = null;
destroyWorldOverlay();
const overlayActionsStore = useWorldOverlayActionsStore(applicationPinia());
overlayActionsStore.connect(null);
overlayActionsStore.publish(null);
this.celestialRing?.destroy();
this._visualSettings.destroy();
}
+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');
});
+1 -1
View File
@@ -61,7 +61,7 @@ export function _initCctvPanel() {
// The picker defers rebuilds while focused; catch up once it lets go.
this.listen(this._cctvSelect, 'blur', () => {
this._cctvSelectSource = null;
this._picker = null;
this._renderCctvState(this._cctvState);
});
+55 -76
View File
@@ -1,5 +1,4 @@
import { formatNumber, t, msg } from '../i18n/index.js';
import { createNumberInput } from './formField.js';
import { formatNumber, isolate, msg, t } from '../i18n/index.js';
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
@@ -79,13 +78,14 @@ function calValueText(field, value) {
useGrouping: false,
...options,
});
return `${t(field.label)} ${t('{value}m', { value: metres })}`;
return `${t(field.label)} ${isolate(t('{value}m', { value: metres }))}`;
}
const number = formatNumber(Number(value), {
useGrouping: false,
...options,
});
return `${t(field.label)} ${number}${field.unit}`;
// Isolated so a minus sign stays on its number in right-to-left text.
return `${t(field.label)} ${isolate(`${number}${field.unit}`)}`;
}
export function _activeCctvCameraId() {
@@ -111,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);
});
+151 -83
View File
@@ -1,123 +1,116 @@
// 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';
/** The slice of a <select> and its document the picker touches. */
function pickerDocument() {
const select = new EventTarget();
const option = () => ({
value: '',
textContent: '',
disabled: false,
dataset: {},
remove() {
select.options = select.options.filter((entry) => entry !== this);
},
});
Object.assign(select, {
options: [],
disabled: false,
selectedIndex: -1,
get value() {
return this.options[this.selectedIndex]?.value ?? '';
},
set value(id) {
this.selectedIndex = this.options.findIndex((o) => o.value === id);
},
replaceChildren(fragment) {
this.options = [...fragment.children];
this.selectedIndex = this.options.length ? 0 : -1;
},
prepend(child) {
this.options.unshift(child);
},
});
const document = {
activeElement: null,
createElement: option,
createDocumentFragment: () => ({
children: [],
appendChild(child) {
this.children.push(child);
},
}),
};
return { document, select };
}
import { useCctvStore } from './stores/cctv.js';
import CctvPanel from './views/panels/CctvPanel.vue';
function fixture(t) {
const { document, select } = pickerDocument();
const previous = globalThis.document;
globalThis.document = document;
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: { _cctvSelect: select },
elements: {
_cctvPanel: document.getElementById('cctv-panel'),
_cctvSelect: select,
},
cctv: {},
actions: { isEnabled: () => true, setPanelCollapsed() {} },
actions: {
isEnabled: () => true,
setPanelCollapsed() {},
syncViewport() {},
},
store: useCctvStore(pinia),
});
t.after(() => {
controls.destroy();
globalThis.document = previous;
app.unmount();
});
return { controls, select };
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', (t) => {
const { controls, select } = fixture(t);
controls._renderCctvState({
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(
select.options.map((o) => o.value),
['a', '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');
controls._renderCctvState({
await render({
enabled: true,
count: 2,
cameraIndex: [camera('a'), camera('b')],
});
assert.deepEqual(
select.options.map((o) => o.value),
values(),
['a', 'b'],
'a view holding every camera has no note',
);
});
test('with no camera in view, the closed picker shows the note', (t) => {
const { controls, select } = fixture(t);
controls._renderCctvState({ enabled: true, count: 1, cameraIndex: [] });
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', (t) => {
const { controls, select } = fixture(t);
controls._renderCctvState({ enabled: false, count: 40, cameraIndex: [] });
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('a camera activated under a focused picker counts against the note', (t) => {
const { controls, select } = fixture(t);
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');
controls._renderCctvState({
await render({
enabled: true,
count: 3,
cameraIndex: [a],
@@ -125,36 +118,111 @@ test('a camera activated under a focused picker counts against the note', (t) =>
activeCamera: a,
});
assert.equal(
select.options.at(-1).textContent,
select.options[select.options.length - 1].textContent,
'2 more cameras outside this view · pan or zoom out',
);
globalThis.document.activeElement = select;
controls._renderCctvState({
select.focus();
await render({
enabled: true,
count: 3,
cameraIndex: [b, a],
activeCameraId: 'b',
activeCamera: b,
});
assert.deepEqual(
select.options.map((o) => o.value),
['b', 'a', ''],
);
assert.deepEqual(values(), ['b', 'a', '']);
assert.equal(
select.options.at(-1).textContent,
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 does not pick a stale camera when the view empties', (t) => {
const { controls, select } = fixture(t);
controls._renderCctvState({
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: 3,
cameraIndex: [camera('a')],
count: 1,
cameraIndex: [a],
activeCameraId: 'a',
activeCamera: a,
});
globalThis.document.activeElement = select;
controls._renderCctvState({ enabled: true, count: 3, cameraIndex: [] });
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');
});
+202 -204
View File
@@ -1,8 +1,8 @@
import { updateStatusBadge } from './statusBadge.js';
import { 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,11 @@ 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(
@@ -25,6 +30,109 @@ function outsideNoteText(count) {
);
}
/**
* 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 (
@@ -68,207 +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) {
// The index keeps its identity until the view or the active camera
// changes, so an unchanged list skips the per-option comparison. A
// focused picker is not rebuilt under the user; blur re-renders it.
const picking = document.activeElement === this._cctvSelect;
// A focused picker keeps the options it shows; blur re-renders it. One
// about to be disabled cannot hold focus, so it does not count.
const disabled = !enabled || cameras.length === 0;
const picking = document.activeElement === this._cctvSelect && !disabled;
// Cameras the view leaves out get one closing line, so a short list
// does not read as the whole catalog. A disabled layer has no view.
const outside = enabled ? Math.max(0, catalogCount - cameras.length) : 0;
const shouldRebuild =
!picking &&
(this._cctvSelectSource !== cameras ||
this._cctvSelectOutside !== outside) &&
(this._cctvSelectOutside !== outside ||
this._cctvSelect.options.length !==
cameras.length + (outside ? 1 : 0) ||
cameras.some(
(cam, idx) => this._cctvSelect.options[idx]?.value !== cam.id,
));
if (!picking) {
this._cctvSelectSource = cameras;
this._cctvSelectOutside = outside;
}
if (shouldRebuild) {
const options = document.createDocumentFragment();
for (const camera of cameras) {
const option = document.createElement('option');
option.value = camera.id;
option.textContent = `${camera.city} · ${camera.name}`;
options.appendChild(option);
}
if (outside) {
const note = document.createElement('option');
note.value = '';
note.disabled = true;
note.dataset.cctvOutside = '';
note.textContent = outsideNoteText(outside);
options.appendChild(note);
}
this._cctvSelect.replaceChildren(options);
}
this._cctvSelect.disabled = !enabled || cameras.length === 0;
// A deferred rebuild can miss a camera activated meanwhile (auto-hop,
// voice); add it so the picker does not go blank.
let picker = this._picker;
// The index keeps its identity until the view or the active camera
// changes, so an unchanged list keeps its options; a new locale words
// the closing line again.
if (
!picking &&
(picker?.source !== cameras ||
picker.outside !== outside ||
picker.locale !== getLocale())
)
picker = {
source: cameras,
outside,
locale: getLocale(),
options: cameras.map((camera) => pickerOption(camera)),
note: outside ? outsideNoteText(outside) : '',
};
else if (
picking &&
activeId &&
activeCamera &&
![...this._cctvSelect.options].some((option) => option.value === activeId)
!picker?.options.some((option) => option.value === activeId)
) {
const option = document.createElement('option');
option.value = activeId;
option.textContent = `${activeCamera.city} · ${activeCamera.name}`;
this._cctvSelect.prepend(option);
// The added camera is one fewer outside the view.
const note = [...this._cctvSelect.options].find(
(entry) => entry.dataset?.cctvOutside !== undefined,
);
if (note) {
const left = Math.max(
0,
catalogCount - (this._cctvSelect.options.length - 1),
);
if (left) note.textContent = outsideNoteText(left);
else note.remove();
}
// 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) : '',
};
}
// The active camera is always one of the listed records.
if (activeId && activeCamera) {
this._cctvSelect.value = activeId;
} else if (!activeId) {
// With nothing in view, the closed picker says where the cameras are.
// A focused picker keeps its old options, so it only clears.
this._cctvSelect.selectedIndex =
!picking && cameras.length === 0 && outside ? 0 : -1;
}
}
for (const btn of [
this._cctvNearestBtn,
this._cctvPrevBtn,
this._cctvNextBtn,
]) {
if (!btn) continue;
btn.disabled = !enabled || catalogCount === 0;
}
if (this._cctvFocusBtn) {
this._cctvFocusBtn.disabled = !enabled || catalogCount === 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 = 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 (catalogCount > 0) {
this._cctvMeta.textContent = 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',
);
} 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 &&
@@ -288,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);
}
@@ -322,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;
@@ -349,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 -4
View File
@@ -8,10 +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.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']);
});
+8 -139
View File
@@ -1,5 +1,5 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -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) {
@@ -124,18 +95,19 @@ export function formatCockpitCoordinate(value, positive, negative) {
maximumFractionDigits: 3,
});
const code = value >= 0 ? positive : negative;
// Not «30,203°С»: after a degree sign «С» reads as Celsius.
// Not «30,203°С»: after a degree sign «С» reads as Celsius. Isolated, so
// an Arabic hemisphere letter does not pull the next number into its run.
switch (code) {
case 'N':
return t('{value}°N', { value: amount });
return isolate(t('{value}°N', { value: amount }));
case 'S':
return t('{value}°S', { value: amount });
return isolate(t('{value}°S', { value: amount }));
case 'E':
return t('{value}°E', { value: amount });
return isolate(t('{value}°E', { value: amount }));
case 'W':
return t('{value}°W', { value: amount });
return isolate(t('{value}°W', { value: amount }));
default:
return `${amount}°${compassPointLabel(code)}`;
return isolate(`${amount}°${compassPointLabel(code)}`);
}
}
@@ -168,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', '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 }),
};
}
+19 -1
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"`));
}
@@ -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);
});
+56 -46
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(
@@ -65,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: {
@@ -76,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]),
@@ -86,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 };
@@ -113,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);
@@ -224,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 = 'minimal';
hudLayout.dispatchEvent(new Event('change'));
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() {} };
@@ -277,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');
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) => {
@@ -558,7 +555,7 @@ test('Cyber side panels share one width and one framed surface material', () =>
);
assert.match(
cyberStyles,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-right: 8px;/,
/#right-context-rail\s+:is\(\s*\.cctv-panel-inner,\s*\.weather-panel-inner,\s*\.recent-imagery-panel-inner,\s*\.street-level-panel-inner,\s*\.global-context-panel-inner\s*\) \{\s*padding-inline-end: 8px;/,
);
assert.match(
cyberStyles,
@@ -647,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);
@@ -680,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/);
@@ -781,8 +779,20 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', ()
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: 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'/);
@@ -877,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);
});
+71 -117
View File
@@ -1,27 +1,33 @@
/** 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.
const COLORS = ['#39d0ff', '#ffb547', '#5dff9f', '#ff6b6b', '#8be9ff'];
/** An import error's values, with its counts and limits in local numerals. */
function localizedValues(values) {
return Object.fromEntries(
Object.entries(values || {}).map(([name, value]) => [
name,
typeof value === 'number' ? formatNumber(value) : value,
]),
);
}
/**
* 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}
@@ -34,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 = [];
@@ -49,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;
@@ -63,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,
@@ -115,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;
@@ -203,7 +168,7 @@ export function initGeoFileImport({
item.visible = Boolean(visible);
item.dataSource.show = item.visible;
if (item.visible) scheduleGround();
renderList();
present();
render();
return true;
}
@@ -248,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;
}
@@ -303,7 +266,10 @@ export function initGeoFileImport({
ok: false,
error:
error instanceof GeoImportError || error?.name === 'GeoImportError'
? t(error.template || error.message, error.values)
? t(
error.template || error.message,
localizedValues(error.values),
)
: t('That file could not be read.'),
});
continue;
@@ -335,7 +301,7 @@ export function initGeoFileImport({
}
loaded.mount?.();
imports.push(item);
renderList();
present();
render();
outcomes.push({
name: fileName,
@@ -357,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 ----------------------------------------------
@@ -415,7 +371,7 @@ export function initGeoFileImport({
if (files.length) void importFiles(files);
});
renderList();
present();
const handle = {
importFiles,
@@ -440,7 +396,7 @@ export function initGeoFileImport({
destroy() {
if (destroyed) return;
destroyed = true;
unregisterUnsavedWork();
disconnect();
lifetime.abort();
removeMapSource?.();
clearTimeout(groundTimer);
@@ -452,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;
+78 -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,7 +602,25 @@ 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();
});
test('a refusal shows its counts in the language’s numerals, not the browser’s', async () => {
const t = setup({
load: async () => {
throw new GeoImportError(
'That file has 60,000 map objects; the limit is 50,000.',
'That file has {count} map objects; the limit is {limit}.',
{ count: 60000, limit: 50000 },
);
},
});
const [out] = await t.tool.importFiles([file('huge.kml')]);
assert.equal(
out.error,
'That file has 60,000 map objects; the limit is 50,000.',
);
t.tool.destroy();
});
+175
View File
@@ -0,0 +1,175 @@
// src/ui/globeActions.test.mjs — the top-center globe actions call their
// owners through the globe actions store, and a clear under way keeps its
// button focusable and marks it busy.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useGlobeActionsStore } from './stores/globeActions.js';
import GlobeActions from './views/panels/GlobeActions.vue';
import CockpitView from './views/CockpitView.vue';
function mount(t) {
document.body.replaceChildren();
const pinia = createPinia();
const store = useGlobeActionsStore(pinia);
const calls = [];
const token = store.connect({
clear: () => calls.push('clear'),
share: () => calls.push('share'),
tilt: () => calls.push('tilt'),
northUp: () => calls.push('northUp'),
reset: () => calls.push('reset'),
});
const app = createApp(GlobeActions).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
return { calls, store, token };
}
const $ = (id) => document.getElementById(id);
test('every button calls its owner', (t) => {
const { calls } = mount(t);
$('clear-selected-layers').click();
$('share-btn').click();
$('tilt-map-view').click();
$('north-up-view').click();
$('reset-globe-view').click();
assert.deepEqual(calls, ['clear', 'share', 'tilt', 'northUp', 'reset']);
});
test('the tilt and compass buttons show the published camera', async (t) => {
const { store } = mount(t);
const tilt = $('tilt-map-view');
const north = $('north-up-view');
assert.equal(tilt.getAttribute('aria-pressed'), 'false');
assert.equal(tilt.getAttribute('aria-label'), 'Tilt map to oblique view');
assert.equal(north.getAttribute('aria-label'), 'Reset map to north up');
assert.equal(north.hasAttribute('data-north-up'), false);
assert.equal(north.style.getPropertyValue('--camera-heading'), '');
store.publishCamera({ tilted: true, heading: 0 });
await nextTick();
assert.equal(tilt.getAttribute('aria-pressed'), 'true');
assert.equal(
tilt.getAttribute('aria-label'),
'Return map to straight-down view',
);
assert.equal(north.getAttribute('data-north-up'), 'true');
assert.equal(north.style.getPropertyValue('--camera-heading'), '0deg');
store.publishCamera({ tilted: false, heading: 47 });
await nextTick();
assert.equal(tilt.getAttribute('aria-pressed'), 'false');
assert.equal(north.getAttribute('data-north-up'), 'false');
assert.equal(north.style.getPropertyValue('--camera-heading'), '47deg');
assert.equal(
north.getAttribute('aria-label'),
'Reset map to north up. Current heading 47 degrees',
);
// A disposed shell hands the next one an unmeasured compass.
store.publishCamera(null);
await nextTick();
assert.equal(north.hasAttribute('data-north-up'), false);
assert.equal(north.style.getPropertyValue('--camera-heading'), '');
});
test('a clear under way marks the button busy and keeps it focusable', async (t) => {
const { store, token } = mount(t);
const clear = $('clear-selected-layers');
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
assert.equal(clear.hasAttribute('aria-busy'), false);
store.publishClearBusy(token, true);
await nextTick();
assert.equal(clear.getAttribute('aria-busy'), 'true');
assert.equal(clear.getAttribute('aria-disabled'), 'true');
assert.equal(
clear.getAttribute('aria-label'),
'Clearing selected data layers',
);
assert.equal(clear.disabled, false);
store.publishClearBusy(token, false);
await nextTick();
assert.equal(clear.getAttribute('aria-busy'), 'false');
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
// The button shows the clear of the owner that answers it: a newer one's,
// then the older one's again once the newer goes.
store.publishClearBusy(token, true);
const newer = store.connect({ clear() {} });
await nextTick();
assert.equal(clear.hasAttribute('aria-busy'), false);
store.publishClearBusy(newer, false);
await nextTick();
assert.equal(clear.getAttribute('aria-busy'), 'false');
store.detach(newer);
await nextTick();
assert.equal(clear.getAttribute('aria-busy'), 'true');
// A shell disposed mid-clear hands the next one a bar that is not busy.
store.detach(token);
await nextTick();
assert.equal(clear.hasAttribute('aria-busy'), false);
assert.equal(clear.hasAttribute('aria-disabled'), false);
assert.equal(clear.getAttribute('aria-label'), 'Clear selected data layers');
});
test('a detached owner no longer answers', (t) => {
const { calls, store } = mount(t);
const token = store.connect({ share: () => calls.push('newer share') });
$('share-btn').click();
store.detach(token);
$('share-btn').click();
assert.deepEqual(calls, ['newer share', 'share']);
});
test('the same camera state again changes nothing', () => {
const store = useGlobeActionsStore(createPinia());
store.publishCamera({ tilted: true, heading: 12 });
const kept = store.camera;
store.publishCamera({ tilted: true, heading: 12 });
assert.equal(store.camera, kept);
store.publishCamera({ tilted: true, heading: 13 });
assert.notEqual(store.camera, kept);
assert.deepEqual(store.camera, { tilted: true, heading: 13 });
});
test('a reset under way relabels the reset button for its owner', async (t) => {
const { store, token } = mount(t);
const reset = $('reset-globe-view');
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
store.publishResetting(token, true);
await nextTick();
assert.equal(
reset.getAttribute('aria-label'),
'Resetting to full globe view',
);
assert.equal(store.clearBusy, null, 'a reset is not a clear');
store.detach(token);
await nextTick();
assert.equal(reset.getAttribute('aria-label'), 'Reset to full globe view');
});
test('the cockpit reset asks the reset owner and shows its reset', async (t) => {
document.body.replaceChildren();
const pinia = createPinia();
const store = useGlobeActionsStore(pinia);
const calls = [];
const token = store.connect({ reset: () => calls.push('reset') });
const app = createApp(CockpitView).use(pinia);
app.mount(document.body.appendChild(document.createElement('div')));
t.after(() => app.unmount());
const reset = $('cockpit-reset-globe');
assert.equal(
reset.getAttribute('aria-label'),
'Reset cockpit to full globe view',
);
reset.click();
assert.deepEqual(calls, ['reset']);
store.publishResetting(token, true);
await nextTick();
assert.equal(
reset.getAttribute('aria-label'),
'Resetting cockpit to full globe view',
);
});
+59
View File
@@ -0,0 +1,59 @@
import { watch } from 'vue';
import { createI18n } from 'vue-i18n';
import {
bindLocale,
getCatalog,
getLocale,
notifyLocaleChange,
} from '../i18n/index.js';
import { DEFAULT_LOCALE, LOCALES } from '../i18n/locale.js';
let pageI18n = null;
// Catalogs are flat and keyed by English text, which holds dots.
const resolveFlat = (messages, key) =>
Object.hasOwn(messages, key) ? messages[key] : null;
/**
* The page's vue-i18n instance, installed in every application view. It
* holds the active locale and the catalogs; Core's t(), tn() and the Intl
* formatters read the locale from it, so a view that calls them while
* rendering re-renders when the locale changes. Views translate through
* t()/tn() rather than vue-i18n's own t: English is the key, `context` picks
* an entry, and plural forms follow Intl.PluralRules (docs/I18N.md).
* @returns {import('vue-i18n').I18n}
*/
export function applicationI18n() {
if (pageI18n) return pageI18n;
const messages = {};
for (const locale of LOCALES)
if (locale !== DEFAULT_LOCALE) messages[locale] = getCatalog(locale);
pageI18n = createI18n({
legacy: false,
globalInjection: false,
locale: getLocale(),
fallbackLocale: DEFAULT_LOCALE,
messages,
messageResolver: resolveFlat,
missingWarn: false,
fallbackWarn: false,
});
// vue-i18n disposes its instance when an app it is installed in unmounts;
// the page's outlives every view, so an unmounted panel keeps it.
const install = pageI18n.install;
pageI18n.install = (app, ...options) => {
const unmount = app.unmount;
const installed = install.call(pageI18n, app, ...options);
app.unmount = unmount;
return installed;
};
bindLocale(pageI18n.global.locale);
// Code that translated text itself shows it again, whichever way the
// locale changed (setLocale or the ref).
watch(
() => getLocale(),
() => notifyLocaleChange(),
{ flush: 'sync' },
);
return pageI18n;
}
+96
View File
@@ -0,0 +1,96 @@
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { installVueDom } from '../testSupport/vueDom.mjs';
let vue;
let i18n;
let applicationI18n;
let uninstall;
before(async () => {
uninstall = installVueDom();
vue = await import('vue');
i18n = await import('../i18n/index.js');
({ applicationI18n } = await import('./i18n.js'));
});
after(() => {
i18n.setLocale('en');
uninstall();
});
test('vue-i18n takes the locale set before it and holds the catalogs', () => {
i18n.setLocale('ru');
const { global } = applicationI18n();
assert.equal(applicationI18n(), applicationI18n());
assert.equal(global.locale.value, 'ru');
assert.equal(global.te('Settings'), true);
assert.equal(global.tm('Settings'), 'Настройки');
global.locale.value = 'de';
assert.equal(i18n.getLocale(), 'de');
global.locale.value = 'ru_RU';
assert.equal(i18n.getLocale(), 'ru');
assert.equal(i18n.t('Settings'), 'Настройки');
assert.equal(i18n.formatNumber(1234.5), '1\u00a0234,5');
global.locale.value = 'xx';
assert.equal(i18n.getLocale(), 'en');
i18n.setLocale('en');
assert.equal(global.locale.value, 'en');
});
test('a view that translates re-renders when the locale changes', async () => {
const { createApp, h, nextTick } = vue;
const host = document.createElement('div');
document.body.append(host);
const app = createApp({
render: () =>
h('p', [
i18n.t('Settings'),
' / ',
i18n.tn(
3,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
),
]),
}).use(applicationI18n());
app.mount(host);
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
i18n.setLocale('ru');
await nextTick();
assert.equal(host.textContent, 'Настройки / ЗАПУСК · 3 КЛЮЧА В ОЖИДАНИИ');
i18n.setLocale('en');
await nextTick();
assert.equal(host.textContent, 'Settings / POWER UP · 3 KEYS WAITING');
app.unmount();
host.remove();
});
test('unmounting one view keeps the page instance for the others', async () => {
const { createApp, h, nextTick } = vue;
const page = applicationI18n();
let disposed = 0;
const dispose = page.dispose;
page.dispose = () => {
disposed += 1;
dispose.call(page);
};
const mountView = () => {
const host = document.createElement('div');
document.body.append(host);
const app = createApp({ render: () => h('p', i18n.t('Settings')) });
app.use(page).mount(host);
return { app, host };
};
const first = mountView();
const second = mountView();
first.app.unmount();
assert.equal(disposed, 0);
i18n.setLocale('ru');
await nextTick();
assert.equal(second.host.textContent, 'Настройки');
const third = mountView();
assert.equal(third.host.textContent, 'Настройки');
i18n.setLocale('en');
second.app.unmount();
third.app.unmount();
page.dispose = dispose;
});
+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 593 files, more files were not shown because too many files have changed in this diff. Show more